feat(server): runtime-neutral OAuth discovery serving for web-standard hosts (#2422)

This commit is contained in:
Felix Weinberger 2026-07-06 18:02:06 +01:00 • committed by GitHub
commit 61866d7a5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 465 additions and 76 deletions

View file

@ -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`.

View file

@ -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` /

View file

@ -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<Response> {
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.

View file

@ -83,3 +83,13 @@ function buildServer(): McpServer {
return server;
}
//#region oauthMetadataResponse_webStandard
import { oauthMetadataResponse } from '@modelcontextprotocol/server';
async function webStandardFetch(request: Request): Promise<Response> {
return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request);
}
//#endregion oauthMetadataResponse_webStandard
declare function serveMcp(request: Request): Promise<Response>;

View file

@ -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,12 @@ export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResource
}
/**
* Options for {@link mcpAuthMetadataRouter}.
* 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.
*/
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;
}
export type { AuthMetadataOptions } from '@modelcontextprotocol/server';
/**
* Builds an Express router that serves the two OAuth discovery documents an
@ -101,7 +65,7 @@ export interface AuthMetadataOptions {
* - `/.well-known/oauth-protected-resource[/<path>]` — 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:
*
@ -113,19 +77,21 @@ export interface AuthMetadataOptions {
* `getOAuthProtectedResourceMetadataUrl` as its `resourceMetadataUrl`
* so unauthenticated clients can discover the AS from the 401 challenge.
*/
export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router {
checkIssuerUrl(new URL(options.oauthMetadata.issuer));
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.
dangerouslyAllowInsecureIssuerUrl: allowInsecureIssuerUrl || options.dangerouslyAllowInsecureIssuerUrl
});
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 +102,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';

View file

@ -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';

View file

@ -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;
}

View file

@ -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<Response>) {
//#region oauthMetadataResponse_fetchHandler
async function fetchHandler(request: Request): Promise<Response> {
return oauthMetadataResponse(request, options) ?? serveMcp(request);
}
//#endregion oauthMetadataResponse_fetchHandler
return fetchHandler;
}

View file

@ -0,0 +1,180 @@
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 {
// 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 {
// 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[/<path>]` — 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<Response> {
* 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.
// Tolerate a single trailing slash, as path-mounted routers do.
const requestPath = stripTrailingSlash(new URL(request.url).pathname);
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;
}

View file

@ -0,0 +1,186 @@
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('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);
});
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');
});
});