Claude.ai's connector setup derives the protected-resource metadata URL from the MCP server URL and fetches it before any 401 challenge: /.well-known/oauth-protected-resource/api/extensions/ext/mcp-server/mcp /api/extensions/ext/mcp-server/mcp/.well-known/oauth-protected-resource Both were 404 (only the root document our WWW-Authenticate header points at existed), which the dialog reported as "Authorization with Accounted failed". One shared builder now serves all three locations; the path-based route answers 404 for any path other than the MCP endpoint. Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
51 lines
2.1 KiB
TypeScript
51 lines
2.1 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { mcpServerExtension } from '../index'
|
|
|
|
// Endpoint-appended RFC 9728 discovery: Claude.ai's connector setup tries
|
|
// <server url>/.well-known/oauth-protected-resource before any 401 and turns
|
|
// a 404 into "Authorization failed" (production, 2026-08-26).
|
|
|
|
function findRoute(method: string, path: string) {
|
|
const route = mcpServerExtension.apiRoutes?.find((r) => r.method === method && r.path === path)
|
|
if (!route) throw new Error(`route ${method} ${path} not declared`)
|
|
return route
|
|
}
|
|
|
|
describe('GET /mcp/.well-known/oauth-protected-resource (dispatcher route)', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('is declared unauthenticated and answers the same document as the root location', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se')
|
|
const route = findRoute('GET', '/mcp/.well-known/oauth-protected-resource')
|
|
expect(route.skipAuth).toBe(true)
|
|
const response = await route.handler(
|
|
new Request(
|
|
'https://app.accounted.se/api/extensions/ext/mcp-server/mcp/.well-known/oauth-protected-resource?tool_namespace=accounted',
|
|
{ headers: { host: 'app.accounted.se' } }
|
|
),
|
|
undefined as never
|
|
)
|
|
expect(response.status).toBe(200)
|
|
const body = await response.json()
|
|
expect(body.resource).toBe(
|
|
'https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted'
|
|
)
|
|
expect(body.authorization_servers).toEqual(['https://app.accounted.se'])
|
|
})
|
|
|
|
it('still refuses a foreign browser origin (DNS-rebinding defense)', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se')
|
|
const route = findRoute('GET', '/mcp/.well-known/oauth-protected-resource')
|
|
const response = await route.handler(
|
|
new Request(
|
|
'https://app.accounted.se/api/extensions/ext/mcp-server/mcp/.well-known/oauth-protected-resource',
|
|
{ headers: { host: 'app.accounted.se', origin: 'https://evil.example' } }
|
|
),
|
|
undefined as never
|
|
)
|
|
expect(response.status).toBe(403)
|
|
})
|
|
})
|