Summary
When an MCP endpoint is served under a path (the common /mcp case), the default behaviour of mcpAuthRouter and requireBearerAuth produces an OAuth discovery surface that violates the MCP authorization spec and RFC 9728, and nothing warns the server author:
mcpAuthRouter({...}) without resourceServerUrl falls back to baseUrl, so Protected Resource Metadata (PRM) is mounted only at /.well-known/oauth-protected-resource with resource: "https://host/" — while the client connected to https://host/mcp.
requireBearerAuth({ verifier }) without resourceMetadataUrl returns a 401 whose WWW-Authenticate has no resource_metadata parameter.
Each option is documented as "optional", but for an MCP server both are effectively required:
- MCP Authorization, Authorization Server Location: "MCP servers MUST use the HTTP header
WWW-Authenticate when returning a 401 Unauthorized to indicate the location of the resource server metadata URL as described in RFC9728 Section 5.1."
- RFC 9728 §3 puts the metadata for a resource with a path at
/.well-known/oauth-protected-resource/<path>; §3.3: "The resource value returned MUST be identical to the protected resource's resource identifier … If these values are not identical, the data contained in the response MUST NOT be used."
The reference client in this SDK masks the problem: discoverMetadataWithFallback falls back to the root well-known and selectResourceURL → checkResourceAllowed accepts a resource that is a parent path of the endpoint. Clients that enforce §3.3 literally do not: Gemini CLI throws ResourceMismatchError (packages/core/src/mcp/oauth-utils.ts, exact protocol//host+pathname comparison) and aborts discovery before ever reaching registration_endpoint, so dynamic client registration never starts. Antigravity CLI (agy) behaves the same. The result for server authors is "works in Claude Code / Codex, silently fails in Gemini CLI / Antigravity" with no hint that the server is at fault.
Reproduction
@modelcontextprotocol/sdk 1.25.1 (same on 1.29.0 and main), Node 24.
import express from 'express';
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
const BASE_URL = 'https://mcp.example.com';
const provider = new ProxyOAuthServerProvider({
endpoints: { authorizationUrl: 'https://as.example.com/authorize', tokenUrl: 'https://as.example.com/token' },
verifyAccessToken: async () => { throw new InvalidTokenError('invalid'); },
getClient: async () => undefined,
});
const app = express();
// What the README-level wiring looks like today: only baseUrl/issuerUrl, no resourceServerUrl / resourceMetadataUrl
app.use(mcpAuthRouter({ provider, issuerUrl: new URL(BASE_URL), baseUrl: new URL(BASE_URL), scopesSupported: ['s1'] }));
app.all('/mcp', requireBearerAuth({ verifier: provider }), (_req, res) => res.json({ ok: true }));
app.listen(3000);
$ curl -si -X POST localhost:3000/mcp -H 'Content-Type: application/json' -d '{}' | grep -i www-authenticate
WWW-Authenticate: Bearer error="invalid_token", error_description="Missing Authorization header"
$ curl -s localhost:3000/.well-known/oauth-protected-resource
{"resource":"https://mcp.example.com/","authorization_servers":["https://mcp.example.com/"],"scopes_supported":["s1"]}
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/.well-known/oauth-protected-resource/mcp
404
Passing the two options fixes the surface (path-scoped PRM with resource: ".../mcp", resource_metadata in the challenge), but the default silently produces the broken shape above.
Suggested changes
- Docs/JSDoc (v1.x and main) — say explicitly that
resourceServerUrl should be the MCP endpoint URL (not the origin) and that resourceMetadataUrl is required by the MCP spec, with the consequence of omitting each. I have a small PR ready for both branches.
- v2 (breaking changes acceptable) — consider making the intent impossible to miss, e.g. require
resourceServerUrl in mcpAuthRouter (the neutral AuthMetadataOptions already requires it) and/or require resourceMetadataUrl in BearerAuthOptions, or accept a single mcpServerUrl and derive both.
- Optionally, a one-time warning in v1.x when
requireBearerAuth runs without resourceMetadataUrl.
Happy to open the PRs once the direction is agreed.
Summary
When an MCP endpoint is served under a path (the common
/mcpcase), the default behaviour ofmcpAuthRouterandrequireBearerAuthproduces an OAuth discovery surface that violates the MCP authorization spec and RFC 9728, and nothing warns the server author:mcpAuthRouter({...})withoutresourceServerUrlfalls back tobaseUrl, so Protected Resource Metadata (PRM) is mounted only at/.well-known/oauth-protected-resourcewithresource: "https://host/"— while the client connected tohttps://host/mcp.requireBearerAuth({ verifier })withoutresourceMetadataUrlreturns a401whoseWWW-Authenticatehas noresource_metadataparameter.Each option is documented as "optional", but for an MCP server both are effectively required:
WWW-Authenticatewhen returning a 401 Unauthorized to indicate the location of the resource server metadata URL as described in RFC9728 Section 5.1."/.well-known/oauth-protected-resource/<path>; §3.3: "Theresourcevalue returned MUST be identical to the protected resource's resource identifier … If these values are not identical, the data contained in the response MUST NOT be used."The reference client in this SDK masks the problem:
discoverMetadataWithFallbackfalls back to the root well-known andselectResourceURL→checkResourceAllowedaccepts aresourcethat is a parent path of the endpoint. Clients that enforce §3.3 literally do not: Gemini CLI throwsResourceMismatchError(packages/core/src/mcp/oauth-utils.ts, exactprotocol//host+pathnamecomparison) and aborts discovery before ever reachingregistration_endpoint, so dynamic client registration never starts. Antigravity CLI (agy) behaves the same. The result for server authors is "works in Claude Code / Codex, silently fails in Gemini CLI / Antigravity" with no hint that the server is at fault.Reproduction
@modelcontextprotocol/sdk1.25.1 (same on 1.29.0 andmain), Node 24.Passing the two options fixes the surface (path-scoped PRM with
resource: ".../mcp",resource_metadatain the challenge), but the default silently produces the broken shape above.Suggested changes
resourceServerUrlshould be the MCP endpoint URL (not the origin) and thatresourceMetadataUrlis required by the MCP spec, with the consequence of omitting each. I have a small PR ready for both branches.resourceServerUrlinmcpAuthRouter(the neutralAuthMetadataOptionsalready requires it) and/or requireresourceMetadataUrlinBearerAuthOptions, or accept a singlemcpServerUrland derive both.requireBearerAuthruns withoutresourceMetadataUrl.Happy to open the PRs once the direction is agreed.