Skip to content

Server auth defaults silently produce RFC 9728-noncompliant discovery when the MCP endpoint has a path #2751

Description

@bumhwan

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:

  1. 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.
  2. 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 selectResourceURLcheckResourceAllowed 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

  1. 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.
  2. 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.
  3. Optionally, a one-time warning in v1.x when requireBearerAuth runs without resourceMetadataUrl.

Happy to open the PRs once the direction is agreed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions