1307d4db2e
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>
47 lines
2.2 KiB
TypeScript
47 lines
2.2 KiB
TypeScript
import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url'
|
|
|
|
/** Path of the one protected resource this server advertises: the MCP endpoint. */
|
|
export const MCP_RESOURCE_PATH = '/api/extensions/ext/mcp-server/mcp'
|
|
|
|
/**
|
|
* RFC 9728 Protected Resource Metadata for the MCP endpoint.
|
|
*
|
|
* Served from three URLs, because clients derive the location differently:
|
|
* - `/.well-known/oauth-protected-resource` (root): what our 401
|
|
* `WWW-Authenticate: resource_metadata=` header points at; Claude Code
|
|
* and the stdio bridges follow that hint.
|
|
* - `/.well-known/oauth-protected-resource/api/extensions/ext/mcp-server/mcp`
|
|
* (RFC 9728 §3.1 path-based form): Claude.ai's connector setup derives
|
|
* this from the server URL and fetches it BEFORE any 401, so a 404 here
|
|
* reads as "Authorization failed" in the connector dialog.
|
|
* - `/api/extensions/ext/mcp-server/mcp/.well-known/oauth-protected-resource`
|
|
* (endpoint-appended form, tried by the same client as a fallback).
|
|
*
|
|
* The resource/AS URLs reflect the (allowlisted) request host: MCP clients
|
|
* validate the advertised resource against the server URL they were
|
|
* configured with, and existing connectors point at the legacy
|
|
* app.gnubok.se domain after the app.accounted.se cutover.
|
|
*/
|
|
export function buildProtectedResourceMetadata(request: Request): {
|
|
resource: string
|
|
authorization_servers: string[]
|
|
scopes_supported: string[]
|
|
} {
|
|
const appUrl = resolveDiscoveryBaseUrl(request)
|
|
const resource = new URL(MCP_RESOURCE_PATH, appUrl)
|
|
// `accounted` is the COMPLETE allow-list of reflectable namespaces. We never
|
|
// echo the inbound parameter value: on an exact match we set the fixed
|
|
// literal, so a crafted tool_namespace (URL-special chars, other values) can
|
|
// never reach the advertised resource URL. Do not loosen this to a broader
|
|
// match without re-checking every downstream consumer that parses `resource`.
|
|
if (new URL(request.url).searchParams.get('tool_namespace') === 'accounted') {
|
|
resource.searchParams.set('tool_namespace', 'accounted')
|
|
}
|
|
|
|
return {
|
|
resource: resource.toString(),
|
|
authorization_servers: [appUrl],
|
|
scopes_supported: ['mcp'],
|
|
}
|
|
}
|