From 543db9ec9297cf2e0ea6245c0191f95a932f1c65 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 16:57:24 +0000 Subject: [PATCH 1/4] feat(server): runtime-neutral OAuth discovery serving for web-standard hosts The RFC 9728 Protected Resource Metadata and RFC 8414 Authorization Server metadata documents could only be served through the Express metadata router, so fetch-handler hosts (Cloudflare Workers, Deno, Bun, Hono) hand-rolled the well-known routes, CORS, and method handling. The core moves to @modelcontextprotocol/server as oauthMetadataResponse (a Response-or-undefined matcher in the style of hostHeaderValidationResponse), built on the exported buildOAuthProtectedResourceMetadata, with getOAuthProtectedResourceMetadataUrl now defined here. The Express router adapts the same core with unchanged behavior, proven by its untouched test suite. The neutral core improves on the Express quirks where green-field allows: matching happens before validation so unmatched traffic always falls through (a misconfigured issuer surfaces on the discovery routes, or at startup via an eager buildOAuthProtectedResourceMetadata call, never as a whole-server outage), a single trailing slash is tolerated, HEAD is served per RFC 9110, reflected CORS preflights carry Vary, and the insecure-issuer escape hatch is an explicit dangerouslyAllowInsecureIssuerUrl option instead of a module-scope environment read. --- .changeset/web-standard-oauth-metadata.md | 17 ++ docs/migration/upgrade-to-v2.md | 4 +- docs/serving/authorization.md | 10 + .../guides/serving/authorization.examples.ts | 10 + .../express/src/auth/metadataRouter.ts | 98 +++------- packages/server/src/index.ts | 8 + .../middleware/oauthMetadata.examples.ts | 23 +++ .../src/server/middleware/oauthMetadata.ts | 175 +++++++++++++++++ .../server/test/server/oauthMetadata.test.ts | 178 ++++++++++++++++++ 9 files changed, 450 insertions(+), 73 deletions(-) create mode 100644 .changeset/web-standard-oauth-metadata.md create mode 100644 packages/server/src/server/middleware/oauthMetadata.examples.ts create mode 100644 packages/server/src/server/middleware/oauthMetadata.ts create mode 100644 packages/server/test/server/oauthMetadata.test.ts diff --git a/.changeset/web-standard-oauth-metadata.md b/.changeset/web-standard-oauth-metadata.md new file mode 100644 index 0000000000..32b960fa44 --- /dev/null +++ b/.changeset/web-standard-oauth-metadata.md @@ -0,0 +1,17 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/express': patch +--- + +Add runtime-neutral OAuth discovery serving to `@modelcontextprotocol/server`: +`oauthMetadataResponse` serves the RFC 9728 Protected Resource Metadata and +RFC 8414 Authorization Server metadata documents from web-standard +`fetch(request)` hosts, built on the exported +`buildOAuthProtectedResourceMetadata`, with +`getOAuthProtectedResourceMetadataUrl` now defined here. The Express metadata +router adapts the same core and is unchanged in behavior; the insecure-issuer +escape hatch is an explicit `dangerouslyAllowInsecureIssuerUrl` option in the +neutral core instead of a module-scope environment read. The web-standard +matcher validates lazily so unmatched traffic always falls through, tolerates +a trailing slash, supports HEAD, and marks reflected CORS preflights with +`Vary`. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index a149e5b407..240c1e1834 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -397,7 +397,9 @@ A few transports need a decision the codemod can't make: `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; the runtime-neutral core (`requireBearerAuth` for web-standard `fetch` hosts, `verifyBearerToken`, `bearerAuthChallengeResponse`, - `OAuthTokenVerifier`) is also exported from `@modelcontextprotocol/server`. Authorization Server helpers (`mcpAuthRouter`, + `OAuthTokenVerifier`, and the discovery serving `oauthMetadataResponse` / + `buildOAuthProtectedResourceMetadata` / `getOAuthProtectedResourceMetadataUrl`) + is also exported from `@modelcontextprotocol/server`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`, `authenticateClient`, `metadataHandler`, `createOAuthMetadata`, `authorizationHandler` / `tokenHandler` / `revocationHandler` / diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 5dbaa60a3f..7a0389c77c 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -89,6 +89,16 @@ app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl } The router mounts two well-known routes: `/.well-known/oauth-protected-resource/mcp` — the path-aware RFC 9728 location, the same string `getOAuthProtectedResourceMetadataUrl(mcpServerUrl)` put into the challenge — and `/.well-known/oauth-authorization-server`, a mirror of `oauthMetadata` for clients that probe your origin directly. An unauthenticated client follows `401` → `resource_metadata` → `authorization_servers` to find your AS, obtains a token, and retries. +On a web-standard host, `oauthMetadataResponse` from `@modelcontextprotocol/server` serves the same two documents from a `fetch(request)` handler — it returns the matched document `Response` (with permissive CORS and `405` handling) or `undefined` to fall through to your own routing: + +```ts source="../../examples/guides/serving/authorization.examples.ts#oauthMetadataResponse_webStandard" +import { oauthMetadataResponse } from '@modelcontextprotocol/server'; + +async function webStandardFetch(request: Request): Promise { + return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request); +} +``` + ## Read the caller in your handlers `requireBearerAuth` attaches the verified `AuthInfo` to `req.auth`, `toNodeHandler` forwards it, and tool handlers inside `buildServer` read it as `ctx.http.authInfo` — the exact object your verifier returned. diff --git a/examples/guides/serving/authorization.examples.ts b/examples/guides/serving/authorization.examples.ts index 60dbc41605..bc6887d9e0 100644 --- a/examples/guides/serving/authorization.examples.ts +++ b/examples/guides/serving/authorization.examples.ts @@ -83,3 +83,13 @@ function buildServer(): McpServer { return server; } + +//#region oauthMetadataResponse_webStandard +import { oauthMetadataResponse } from '@modelcontextprotocol/server'; + +async function webStandardFetch(request: Request): Promise { + return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request); +} +//#endregion oauthMetadataResponse_webStandard + +declare function serveMcp(request: Request): Promise; diff --git a/packages/middleware/express/src/auth/metadataRouter.ts b/packages/middleware/express/src/auth/metadataRouter.ts index 7913c88171..3f37fe007e 100644 --- a/packages/middleware/express/src/auth/metadataRouter.ts +++ b/packages/middleware/express/src/auth/metadataRouter.ts @@ -1,5 +1,9 @@ -import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/server'; -import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import type { + AuthMetadataOptions as NeutralAuthMetadataOptions, + OAuthMetadata, + OAuthProtectedResourceMetadata +} from '@modelcontextprotocol/server'; +import { buildOAuthProtectedResourceMetadata, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; import cors from 'cors'; import type { RequestHandler, Router } from 'express'; import express from 'express'; @@ -12,19 +16,6 @@ if (allowInsecureIssuerUrl) { console.warn('MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL is enabled - HTTP issuer URLs are allowed. Do not use in production.'); } -function checkIssuerUrl(issuer: URL): void { - // RFC 8414 technically does not permit a localhost HTTPS exemption, but it is necessary for local testing. - if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecureIssuerUrl) { - throw new Error('Issuer URL must be HTTPS'); - } - if (issuer.hash) { - throw new Error(`Issuer URL must not have a fragment: ${issuer}`); - } - if (issuer.search) { - throw new Error(`Issuer URL must not have a query string: ${issuer}`); - } -} - /** * Express middleware that rejects HTTP methods not in the supplied allow-list * with a 405 Method Not Allowed and an OAuth-style error body. Used by @@ -60,39 +51,14 @@ export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResource } /** - * Options for {@link mcpAuthMetadataRouter}. + * Options for {@link mcpAuthMetadataRouter}. Same shape as the runtime-neutral + * `AuthMetadataOptions` in `@modelcontextprotocol/server`, except the + * insecure-issuer escape hatch is controlled here by the + * `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL` environment variable instead of + * an option. */ -export interface AuthMetadataOptions { - /** - * Authorization Server metadata (RFC 8414) for the AS this MCP server - * relies on. Served at `/.well-known/oauth-authorization-server` so - * legacy clients that probe the resource origin still discover the AS. - */ - oauthMetadata: OAuthMetadata; - - /** - * The public URL of this MCP server, used as the `resource` value in the - * Protected Resource Metadata document. Any path component is reflected - * in the well-known route per RFC 9728. - */ - resourceServerUrl: URL; - - /** - * Optional documentation URL advertised as `resource_documentation`. - */ - serviceDocumentationUrl?: URL; - - /** - * Optional list of scopes this MCP server understands, advertised as - * `scopes_supported`. - */ - scopesSupported?: string[]; - - /** - * Optional human-readable name advertised as `resource_name`. - */ - resourceName?: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface AuthMetadataOptions extends Omit {} /** * Builds an Express router that serves the two OAuth discovery documents an @@ -101,7 +67,7 @@ export interface AuthMetadataOptions { * - `/.well-known/oauth-protected-resource[/]` — RFC 9728 Protected * Resource Metadata, derived from the supplied options. * - `/.well-known/oauth-authorization-server` — RFC 8414 Authorization - * Server Metadata, passed through verbatim from {@link AuthMetadataOptions.oauthMetadata}. + * Server Metadata, passed through verbatim from the supplied `oauthMetadata`. * * Mount this router at the application root: * @@ -114,18 +80,19 @@ export interface AuthMetadataOptions { * so unauthenticated clients can discover the AS from the 401 challenge. */ export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router { - checkIssuerUrl(new URL(options.oauthMetadata.issuer)); + // Explicit fields, not a spread: a wider object cannot smuggle in (or be + // clobbered on) the insecure-issuer flag — here it comes only from the env. + const protectedResourceMetadata = buildOAuthProtectedResourceMetadata({ + oauthMetadata: options.oauthMetadata, + resourceServerUrl: options.resourceServerUrl, + serviceDocumentationUrl: options.serviceDocumentationUrl, + scopesSupported: options.scopesSupported, + resourceName: options.resourceName, + dangerouslyAllowInsecureIssuerUrl: allowInsecureIssuerUrl + }); const router = express.Router(); - const protectedResourceMetadata: OAuthProtectedResourceMetadata = { - resource: options.resourceServerUrl.href, - authorization_servers: [options.oauthMetadata.issuer], - scopes_supported: options.scopesSupported, - resource_name: options.resourceName, - resource_documentation: options.serviceDocumentationUrl?.href - }; - // Serve PRM at the path-aware URL per RFC 9728 §3.1. const rsPath = new URL(options.resourceServerUrl.href).pathname; router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata)); @@ -136,18 +103,5 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router { return router; } -/** - * Builds the RFC 9728 Protected Resource Metadata URL for a given MCP server - * URL by inserting `/.well-known/oauth-protected-resource` ahead of the path. - * - * @example - * ```ts - * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) - * // → 'https://api.example.com/.well-known/oauth-protected-resource/mcp' - * ``` - */ -export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { - const u = new URL(serverUrl.href); - const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : ''; - return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href; -} +// Re-exported from the runtime-neutral home in @modelcontextprotocol/server. +export { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/server'; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1c48319bc0..dd720eb954 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -39,6 +39,14 @@ export type { BearerAuthOptions, OAuthTokenVerifier } from './server/middleware/ export { bearerAuthChallengeResponse, requireBearerAuth, verifyBearerToken } from './server/middleware/bearerAuth'; export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation'; export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation'; +// OAuth discovery documents (RFC 9728 / RFC 8414) for web-standard hosts; the +// Express metadata router in @modelcontextprotocol/express adapts the same core. +export type { AuthMetadataOptions } from './server/middleware/oauthMetadata'; +export { + buildOAuthProtectedResourceMetadata, + getOAuthProtectedResourceMetadataUrl, + oauthMetadataResponse +} from './server/middleware/oauthMetadata'; export type { OriginValidationResult } from './server/middleware/originValidation'; export { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from './server/middleware/originValidation'; export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerRequestResponseMode } from './server/perRequestTransport'; diff --git a/packages/server/src/server/middleware/oauthMetadata.examples.ts b/packages/server/src/server/middleware/oauthMetadata.examples.ts new file mode 100644 index 0000000000..9aff9f11c7 --- /dev/null +++ b/packages/server/src/server/middleware/oauthMetadata.examples.ts @@ -0,0 +1,23 @@ +/** + * Type-checked examples for `oauthMetadata.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { AuthMetadataOptions } from './oauthMetadata'; +import { oauthMetadataResponse } from './oauthMetadata'; + +/** + * Example: serving the discovery documents from a fetch handler. + */ +function oauthMetadataResponse_fetchHandler(options: AuthMetadataOptions, serveMcp: (request: Request) => Promise) { + //#region oauthMetadataResponse_fetchHandler + async function fetchHandler(request: Request): Promise { + return oauthMetadataResponse(request, options) ?? serveMcp(request); + } + //#endregion oauthMetadataResponse_fetchHandler + return fetchHandler; +} diff --git a/packages/server/src/server/middleware/oauthMetadata.ts b/packages/server/src/server/middleware/oauthMetadata.ts new file mode 100644 index 0000000000..48b484eedd --- /dev/null +++ b/packages/server/src/server/middleware/oauthMetadata.ts @@ -0,0 +1,175 @@ +import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core-internal'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal'; + +/** + * Options for {@link oauthMetadataResponse} and + * {@link buildOAuthProtectedResourceMetadata}. + */ +export interface AuthMetadataOptions { + /** + * Authorization Server metadata (RFC 8414) for the AS this MCP server + * relies on. Served at `/.well-known/oauth-authorization-server` so + * legacy clients that probe the resource origin still discover the AS. + */ + oauthMetadata: OAuthMetadata; + + /** + * The public URL of this MCP server, used as the `resource` value in the + * Protected Resource Metadata document. Any path component is reflected + * in the well-known route per RFC 9728. + */ + resourceServerUrl: URL; + + /** + * Optional documentation URL advertised as `resource_documentation`. + */ + serviceDocumentationUrl?: URL; + + /** + * Optional list of scopes this MCP server understands, advertised as + * `scopes_supported`. + */ + scopesSupported?: string[]; + + /** + * Optional human-readable name advertised as `resource_name`. + */ + resourceName?: string; + + /** + * Allow a non-HTTPS issuer URL. Local testing only — never enable in + * production. The Express adapter maps its + * `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL` environment variable here. + */ + dangerouslyAllowInsecureIssuerUrl?: boolean; +} + +function checkIssuerUrl(issuer: URL, allowInsecure: boolean | undefined): void { + // RFC 8414 technically does not permit a localhost HTTPS exemption, but it is necessary for local testing. + if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecure) { + throw new Error('Issuer URL must be HTTPS'); + } + if (issuer.hash) { + throw new Error(`Issuer URL must not have a fragment: ${issuer}`); + } + if (issuer.search) { + throw new Error(`Issuer URL must not have a query string: ${issuer}`); + } +} + +/** + * Derive the RFC 9728 Protected Resource Metadata document from + * {@link AuthMetadataOptions}, validating the Authorization Server issuer URL + * (HTTPS required outside localhost) in the process. + * + * `oauthMetadataResponse` and the Express `mcpAuthMetadataRouter` both build + * on this; use it directly when serving the document through your own + * routing — or call it once at startup to fail fast on a misconfigured + * issuer before any request arrives. + */ +export function buildOAuthProtectedResourceMetadata(options: AuthMetadataOptions): OAuthProtectedResourceMetadata { + checkIssuerUrl(new URL(options.oauthMetadata.issuer), options.dangerouslyAllowInsecureIssuerUrl); + return { + resource: options.resourceServerUrl.href, + authorization_servers: [options.oauthMetadata.issuer], + scopes_supported: options.scopesSupported, + resource_name: options.resourceName, + resource_documentation: options.serviceDocumentationUrl?.href + }; +} + +/** + * Builds the RFC 9728 Protected Resource Metadata URL for a given MCP server + * URL by inserting `/.well-known/oauth-protected-resource` ahead of the path. + * + * @example + * ```ts + * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) + * // → 'https://api.example.com/.well-known/oauth-protected-resource/mcp' + * ``` + */ +export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { + return new URL(protectedResourceMetadataPath(serverUrl), serverUrl).href; +} + +/** The RFC 9728 path-aware well-known path for a resource URL. */ +function protectedResourceMetadataPath(resourceServerUrl: URL): string { + const rsPath = resourceServerUrl.pathname; + return `/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`; +} + +const ALLOWED_METHODS = 'GET, HEAD, OPTIONS'; + +function metadataDocumentResponse(request: Request, metadata: OAuthMetadata | OAuthProtectedResourceMetadata): Response { + // Metadata documents must be fetchable from web-based MCP clients on any + // origin, so every response carries permissive CORS headers. + if (request.method === 'OPTIONS') { + const requestedHeaders = request.headers.get('access-control-request-headers'); + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': ALLOWED_METHODS, + // The reflected allow-list makes the response vary by request: + // without this a shared cache would replay one preflight's + // allow-list against another's headers. + ...(requestedHeaders === null + ? {} + : { 'Access-Control-Allow-Headers': requestedHeaders, Vary: 'Access-Control-Request-Headers' }) + } + }); + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + const error = new OAuthError(OAuthErrorCode.MethodNotAllowed, `The method ${request.method} is not allowed for this endpoint`); + return Response.json(error.toResponseObject(), { + status: 405, + headers: { Allow: ALLOWED_METHODS, 'Access-Control-Allow-Origin': '*' } + }); + } + const response = Response.json(metadata, { headers: { 'Access-Control-Allow-Origin': '*' } }); + // RFC 9110: HEAD is GET without the body, same headers. + return request.method === 'HEAD' ? new Response(null, { status: response.status, headers: response.headers }) : response; +} + +/** + * Serve the two OAuth discovery documents an MCP server acting as a Resource + * Server exposes, from a web-standard `fetch(request)` handler: + * + * - `/.well-known/oauth-protected-resource[/]` — RFC 9728 Protected + * Resource Metadata, derived from the supplied options (path-aware: the + * resource URL's path is reflected in the route). + * - `/.well-known/oauth-authorization-server` — RFC 8414 Authorization + * Server Metadata, passed through verbatim. + * + * Returns the matched document `Response` (JSON with permissive CORS, `405` + * with an `Allow` header for non-GET methods, `204` for CORS preflight), or + * `undefined` when the request path is neither well-known route — fall + * through to your own routing. The framework-free counterpart of + * `mcpAuthMetadataRouter` from `@modelcontextprotocol/express`; pair it with + * `requireBearerAuth` and `getOAuthProtectedResourceMetadataUrl` so + * unauthenticated clients can discover the AS from the `401` challenge. + * + * @example + * ```ts source="./oauthMetadata.examples.ts#oauthMetadataResponse_fetchHandler" + * async function fetchHandler(request: Request): Promise { + * return oauthMetadataResponse(request, options) ?? serveMcp(request); + * } + * ``` + */ +export function oauthMetadataResponse(request: Request, options: AuthMetadataOptions): Response | undefined { + // Match before building: unmatched traffic must fall through untouched, + // even when the options are misconfigured — a bad issuer surfaces on the + // discovery routes (or at startup via buildOAuthProtectedResourceMetadata), + // never on the host's own traffic. + const rawPath = new URL(request.url).pathname; + // Tolerate a single trailing slash, as path-mounted routers do. + const requestPath = rawPath.length > 1 && rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath; + if (requestPath === protectedResourceMetadataPath(options.resourceServerUrl)) { + return metadataDocumentResponse(request, buildOAuthProtectedResourceMetadata(options)); + } + if (requestPath === '/.well-known/oauth-authorization-server') { + buildOAuthProtectedResourceMetadata(options); // issuer validation + return metadataDocumentResponse(request, options.oauthMetadata); + } + return undefined; +} diff --git a/packages/server/test/server/oauthMetadata.test.ts b/packages/server/test/server/oauthMetadata.test.ts new file mode 100644 index 0000000000..66fcc0fdbd --- /dev/null +++ b/packages/server/test/server/oauthMetadata.test.ts @@ -0,0 +1,178 @@ +import type { OAuthMetadata } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import type { AuthMetadataOptions } from '../../src/server/middleware/oauthMetadata'; +import { + buildOAuthProtectedResourceMetadata, + getOAuthProtectedResourceMetadataUrl, + oauthMetadataResponse +} from '../../src/server/middleware/oauthMetadata'; + +const oauthMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com/', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] +}; + +const options: AuthMetadataOptions = { + oauthMetadata, + resourceServerUrl: new URL('https://api.example.com/mcp'), + scopesSupported: ['mcp'], + resourceName: 'Example Server', + serviceDocumentationUrl: new URL('https://docs.example.com/') +}; + +describe('buildOAuthProtectedResourceMetadata', () => { + it('derives the RFC 9728 document', () => { + const prm = buildOAuthProtectedResourceMetadata(options); + expect(prm).toEqual({ + resource: 'https://api.example.com/mcp', + authorization_servers: ['https://auth.example.com/'], + scopes_supported: ['mcp'], + resource_name: 'Example Server', + resource_documentation: 'https://docs.example.com/' + }); + }); + + it('rejects a non-HTTPS issuer', () => { + expect(() => + buildOAuthProtectedResourceMetadata({ ...options, oauthMetadata: { ...oauthMetadata, issuer: 'http://auth.example.com/' } }) + ).toThrow('Issuer URL must be HTTPS'); + }); + + it('exempts localhost and honors the insecure escape hatch', () => { + expect(() => + buildOAuthProtectedResourceMetadata({ ...options, oauthMetadata: { ...oauthMetadata, issuer: 'http://localhost:9000/' } }) + ).not.toThrow(); + expect(() => + buildOAuthProtectedResourceMetadata({ + ...options, + dangerouslyAllowInsecureIssuerUrl: true, + oauthMetadata: { ...oauthMetadata, issuer: 'http://auth.internal/' } + }) + ).not.toThrow(); + }); + + it('rejects issuer URLs with fragments or query strings', () => { + expect(() => + buildOAuthProtectedResourceMetadata({ + ...options, + oauthMetadata: { ...oauthMetadata, issuer: 'https://auth.example.com/#frag' } + }) + ).toThrow('fragment'); + expect(() => + buildOAuthProtectedResourceMetadata({ + ...options, + oauthMetadata: { ...oauthMetadata, issuer: 'https://auth.example.com/?x=1' } + }) + ).toThrow('query string'); + }); +}); + +describe('getOAuthProtectedResourceMetadataUrl', () => { + it('is path-aware', () => { + expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))).toBe( + 'https://api.example.com/.well-known/oauth-protected-resource/mcp' + ); + }); + + it('omits the trailing path for a root resource URL', () => { + expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/'))).toBe( + 'https://api.example.com/.well-known/oauth-protected-resource' + ); + }); +}); + +describe('oauthMetadataResponse', () => { + it('serves the path-aware PRM document with permissive CORS', async () => { + const response = oauthMetadataResponse(new Request('https://api.example.com/.well-known/oauth-protected-resource/mcp'), options); + expect(response?.status).toBe(200); + expect(response?.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(await response?.json()).toMatchObject({ + resource: 'https://api.example.com/mcp', + authorization_servers: ['https://auth.example.com/'] + }); + }); + + it('serves the PRM at the bare well-known path for a root resource URL', () => { + const rootOptions = { ...options, resourceServerUrl: new URL('https://api.example.com/') }; + const response = oauthMetadataResponse(new Request('https://api.example.com/.well-known/oauth-protected-resource'), rootOptions); + expect(response?.status).toBe(200); + }); + + it('mirrors the AS metadata document', async () => { + const response = oauthMetadataResponse(new Request('https://api.example.com/.well-known/oauth-authorization-server'), options); + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ issuer: 'https://auth.example.com/' }); + }); + + it('answers 405 with an Allow header and OAuth error body for non-GET methods', async () => { + const response = oauthMetadataResponse( + new Request('https://api.example.com/.well-known/oauth-authorization-server', { method: 'POST' }), + options + ); + expect(response?.status).toBe(405); + expect(response?.headers.get('Allow')).toBe('GET, HEAD, OPTIONS'); + expect(await response?.json()).toMatchObject({ error: 'method_not_allowed' }); + }); + + it('answers CORS preflight with 204 and reflected request headers', () => { + const response = oauthMetadataResponse( + new Request('https://api.example.com/.well-known/oauth-authorization-server', { + method: 'OPTIONS', + headers: { 'Access-Control-Request-Headers': 'authorization, mcp-protocol-version' } + }), + options + ); + expect(response?.status).toBe(204); + expect(response?.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response?.headers.get('Access-Control-Allow-Methods')).toBe('GET, HEAD, OPTIONS'); + expect(response?.headers.get('Access-Control-Allow-Headers')).toBe('authorization, mcp-protocol-version'); + }); + + it('returns undefined for unmatched paths so the host falls through', () => { + expect(oauthMetadataResponse(new Request('https://api.example.com/mcp'), options)).toBeUndefined(); + expect(oauthMetadataResponse(new Request('https://api.example.com/.well-known/other'), options)).toBeUndefined(); + }); +}); + +describe('review-hardened contracts', () => { + const badIssuer = { ...options, oauthMetadata: { ...oauthMetadata, issuer: 'http://auth.internal/' } }; + + it('never throws for unmatched paths, even with a misconfigured issuer', () => { + expect(oauthMetadataResponse(new Request('https://api.example.com/mcp'), badIssuer)).toBeUndefined(); + }); + + it('surfaces the issuer misconfiguration on the discovery routes only', () => { + expect(() => + oauthMetadataResponse(new Request('https://api.example.com/.well-known/oauth-protected-resource/mcp'), badIssuer) + ).toThrow('Issuer URL must be HTTPS'); + }); + + it('tolerates a single trailing slash like path-mounted routers', () => { + const response = oauthMetadataResponse(new Request('https://api.example.com/.well-known/oauth-protected-resource/mcp/'), options); + expect(response?.status).toBe(200); + }); + + it('supports HEAD with the same headers and no body', async () => { + const response = oauthMetadataResponse( + new Request('https://api.example.com/.well-known/oauth-authorization-server', { method: 'HEAD' }), + options + ); + expect(response?.status).toBe(200); + expect(response?.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(await response?.text()).toBe(''); + }); + + it('marks the reflected preflight allow-list as varying', () => { + const response = oauthMetadataResponse( + new Request('https://api.example.com/.well-known/oauth-protected-resource/mcp', { + method: 'OPTIONS', + headers: { 'Access-Control-Request-Headers': 'authorization' } + }), + options + ); + expect(response?.headers.get('Vary')).toBe('Access-Control-Request-Headers'); + }); +}); From 2310b505b5b4e55dfc642194b376216c13b1ea26 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 17:42:29 +0000 Subject: [PATCH 2/4] express: reuse the neutral AuthMetadataOptions type instead of an empty Omit-extends interface; option and env var both enable the insecure-issuer escape hatch --- .../express/src/auth/metadataRouter.ts | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/middleware/express/src/auth/metadataRouter.ts b/packages/middleware/express/src/auth/metadataRouter.ts index 3f37fe007e..33092f180f 100644 --- a/packages/middleware/express/src/auth/metadataRouter.ts +++ b/packages/middleware/express/src/auth/metadataRouter.ts @@ -51,14 +51,12 @@ export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResource } /** - * Options for {@link mcpAuthMetadataRouter}. Same shape as the runtime-neutral - * `AuthMetadataOptions` in `@modelcontextprotocol/server`, except the - * insecure-issuer escape hatch is controlled here by the - * `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL` environment variable instead of - * an option. + * Options for {@link mcpAuthMetadataRouter}: the runtime-neutral + * `AuthMetadataOptions` from `@modelcontextprotocol/server`. The + * insecure-issuer escape hatch can also be enabled here by the + * `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL` environment variable. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface AuthMetadataOptions extends Omit {} +export type { AuthMetadataOptions } from '@modelcontextprotocol/server'; /** * Builds an Express router that serves the two OAuth discovery documents an @@ -79,16 +77,11 @@ export interface AuthMetadataOptions extends Omit Date: Thu, 2 Jul 2026 17:44:24 +0000 Subject: [PATCH 3/4] warn when the insecure-issuer option is enabled, matching the env var path's loudness --- packages/middleware/express/src/auth/metadataRouter.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/middleware/express/src/auth/metadataRouter.ts b/packages/middleware/express/src/auth/metadataRouter.ts index 33092f180f..8bf8faf8a6 100644 --- a/packages/middleware/express/src/auth/metadataRouter.ts +++ b/packages/middleware/express/src/auth/metadataRouter.ts @@ -78,6 +78,12 @@ export type { AuthMetadataOptions } from '@modelcontextprotocol/server'; * so unauthenticated clients can discover the AS from the 401 challenge. */ export function mcpAuthMetadataRouter(options: NeutralAuthMetadataOptions): Router { + if (options.dangerouslyAllowInsecureIssuerUrl && !allowInsecureIssuerUrl) { + // The env-var path warns at module load; enabling via the option is + // equally loud so an insecure issuer can never be allowed silently. + // eslint-disable-next-line no-console + console.warn('dangerouslyAllowInsecureIssuerUrl is enabled - HTTP issuer URLs are allowed. Do not use in production.'); + } const protectedResourceMetadata = buildOAuthProtectedResourceMetadata({ ...options, // The env var and the option are both honored; either enables it. From 4258f3453a9cb1d9b4a95e460fcc9476417b11e4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 2 Jul 2026 18:41:01 +0000 Subject: [PATCH 4/4] address review: normalize the expected PRM path like the request path; point the resourceMetadataUrl JSDoc at this package A resourceServerUrl with a trailing slash derived an expected well-known path that kept the slash while incoming request paths had theirs stripped, so the PRM route matched neither spelling. Both sides now share one normalization. --- packages/server/src/server/middleware/bearerAuth.ts | 4 ++-- .../server/src/server/middleware/oauthMetadata.ts | 11 ++++++++--- packages/server/test/server/oauthMetadata.test.ts | 8 ++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/middleware/bearerAuth.ts b/packages/server/src/server/middleware/bearerAuth.ts index c91b0c0922..1169e21336 100644 --- a/packages/server/src/server/middleware/bearerAuth.ts +++ b/packages/server/src/server/middleware/bearerAuth.ts @@ -48,8 +48,8 @@ export interface BearerAuthOptions { * `WWW-Authenticate` header on 401/403 responses, per * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}. * - * Typically built with `getOAuthProtectedResourceMetadataUrl` from - * `@modelcontextprotocol/express`. + * Typically built with `getOAuthProtectedResourceMetadataUrl`, exported + * from this package. */ resourceMetadataUrl?: string; } diff --git a/packages/server/src/server/middleware/oauthMetadata.ts b/packages/server/src/server/middleware/oauthMetadata.ts index 48b484eedd..fa5ac5f455 100644 --- a/packages/server/src/server/middleware/oauthMetadata.ts +++ b/packages/server/src/server/middleware/oauthMetadata.ts @@ -94,10 +94,16 @@ export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { /** The RFC 9728 path-aware well-known path for a resource URL. */ function protectedResourceMetadataPath(resourceServerUrl: URL): string { - const rsPath = resourceServerUrl.pathname; + // Normalized like the request path in `oauthMetadataResponse`: a resource + // URL with a trailing slash must not make its own PRM route unreachable. + const rsPath = stripTrailingSlash(resourceServerUrl.pathname); return `/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`; } +function stripTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +} + const ALLOWED_METHODS = 'GET, HEAD, OPTIONS'; function metadataDocumentResponse(request: Request, metadata: OAuthMetadata | OAuthProtectedResourceMetadata): Response { @@ -161,9 +167,8 @@ export function oauthMetadataResponse(request: Request, options: AuthMetadataOpt // even when the options are misconfigured — a bad issuer surfaces on the // discovery routes (or at startup via buildOAuthProtectedResourceMetadata), // never on the host's own traffic. - const rawPath = new URL(request.url).pathname; // Tolerate a single trailing slash, as path-mounted routers do. - const requestPath = rawPath.length > 1 && rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath; + const requestPath = stripTrailingSlash(new URL(request.url).pathname); if (requestPath === protectedResourceMetadataPath(options.resourceServerUrl)) { return metadataDocumentResponse(request, buildOAuthProtectedResourceMetadata(options)); } diff --git a/packages/server/test/server/oauthMetadata.test.ts b/packages/server/test/server/oauthMetadata.test.ts index 66fcc0fdbd..fe3b3c4e20 100644 --- a/packages/server/test/server/oauthMetadata.test.ts +++ b/packages/server/test/server/oauthMetadata.test.ts @@ -150,6 +150,14 @@ describe('review-hardened contracts', () => { ).toThrow('Issuer URL must be HTTPS'); }); + it('serves the PRM when the resource URL itself has a trailing slash', () => { + const slashOptions = { ...options, resourceServerUrl: new URL('https://api.example.com/mcp/') }; + for (const path of ['/.well-known/oauth-protected-resource/mcp', '/.well-known/oauth-protected-resource/mcp/']) { + const response = oauthMetadataResponse(new Request(`https://api.example.com${path}`), slashOptions); + expect(response?.status).toBe(200); + } + }); + it('tolerates a single trailing slash like path-mounted routers', () => { const response = oauthMetadataResponse(new Request('https://api.example.com/.well-known/oauth-protected-resource/mcp/'), options); expect(response?.status).toBe(200);