feat(server): runtime-neutral OAuth discovery serving for web-standard hosts (#2422)
This commit is contained in:
parent
ce2f65db0e
commit
61866d7a5f
10 changed files with 465 additions and 76 deletions
17
.changeset/web-standard-oauth-metadata.md
Normal file
17
.changeset/web-standard-oauth-metadata.md
Normal 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`.
|
||||
|
|
@ -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` /
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
180
packages/server/src/server/middleware/oauthMetadata.ts
Normal file
180
packages/server/src/server/middleware/oauthMetadata.ts
Normal 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;
|
||||
}
|
||||
186
packages/server/test/server/oauthMetadata.test.ts
Normal file
186
packages/server/test/server/oauthMetadata.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue