fix(mcp): RFC 9728 protected-resource metadata path + audience binding
All checks were successful
Deploy to Production / deploy (push) Successful in 1m31s

Codex/RFC review showed that Claude Desktop addresses the MCP resource
as <PUBLIC_URL>/mcp (the streamable-HTTP endpoint) rather than the
base URL. Per RFC 9728 the protected-resource metadata then lives at
.well-known/oauth-protected-resource inserted between host and path:

  https://mcp.buildmymcpserver.com/.well-known/oauth-protected-resource/<slug>/mcp

Runner template now:
  - publishes `resource: <PUBLIC_URL>/mcp`
  - sets WWW-Authenticate to the RFC 9728 well-known URL
  - serves /.well-known/oauth-protected-resource[/*] so the metadata
    answers at both the legacy and RFC paths during transition
  - accepts both audiences (<PUBLIC_URL>/mcp + <PUBLIC_URL>) during
    rollout so already-issued tokens keep working

API:
  - resolveServerByResource() tries port first, then path segment
    (production path-routing), with a guard against treating "mcp" as
    a tenant slug
  - AS metadata advertises resource_parameter_supported: true

nginx (scripts/setup-runner-tls.sh + scripts/bmm-mcp-runners.nginx):
  - new location matches /.well-known/oauth-protected-resource/<slug>/...
    and proxies to the slug's runner with the slug stripped, so the
    runner sees the local well-known path

Docs (oauth + api-reference) updated to the RFC paths.
This commit is contained in:
Marco Sadjadi
2026-05-28 20:54:27 +02:00
parent 1d845abf92
commit 4d136c4fb2
6 changed files with 161 additions and 38 deletions

View File

@@ -41,24 +41,9 @@ function pkceVerify(verifier: string, challenge: string, method: string): boolea
async function resolveServerByResource(resource: string) {
const url = new URL(resource);
// Path routing (the prod topology on mcp.buildmymcpserver.com): the slug
// is the first path segment. Has to be checked BEFORE the subdomain
// heuristic below, otherwise we extract "mcp" from "mcp.example.com/<slug>"
// and look up the wrong (or no) server. Claude Desktop's RFC 8707 resource
// parameter matches the `resource` field we publish in /.well-known/
// oauth-protected-resource, which is exactly the path-routed public URL.
const firstSegment = url.pathname.split('/').filter(Boolean)[0];
if (firstSegment) {
const [s] = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.slug, firstSegment))
.limit(1);
if (s) return s;
}
// Port-based lookup — used in local dev where the runner is reached
// directly at http://<RUNNER_HOST>:<port>.
// Local direct runner URLs are addressed by host port, e.g.
// http://localhost:4103/mcp. Resolve those before path routing so the
// transport endpoint segment is not treated as a tenant slug.
const port = url.port ? Number(url.port) : null;
if (port !== null) {
const [s] = await db
@@ -69,6 +54,22 @@ async function resolveServerByResource(resource: string) {
if (s) return s;
}
// Path routing (the prod topology on mcp.buildmymcpserver.com): the slug
// is the first path segment. Has to be checked BEFORE the subdomain
// heuristic below, otherwise we extract "mcp" from "mcp.example.com/<slug>/mcp"
// and look up the wrong server. Claude Desktop's RFC 8707 resource parameter
// matches the `resource` field we publish in RFC 9728 protected resource
// metadata, which is the path-routed MCP endpoint URL.
const firstSegment = url.pathname.split('/').filter(Boolean)[0];
if (firstSegment) {
const [s] = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.slug, firstSegment))
.limit(1);
if (s) return s;
}
// Subdomain routing — legacy / future <slug>.mcp.example.com setup.
const slug = url.hostname.split('.')[0];
if (slug && slug !== 'mcp') {
@@ -119,6 +120,7 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
'none',
],
scopes_supported: ['mcp:read', 'mcp:write'],
resource_parameter_supported: true,
};
};
const asMetadataHandler = async (_req: unknown, reply: { send: (body: unknown) => unknown }) =>
@@ -439,4 +441,3 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
});
});
}