feat(express): restore Resource-Server auth glue (requireBearerAuth, mcpAuthMetadataRouter) (#1907)

This commit is contained in:
Felix Weinberger 2026-04-28 15:18:33 +01:00 • committed by GitHub
commit 7cccc2aca8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 693 additions and 184 deletions

View file

@ -0,0 +1,5 @@
---
'@modelcontextprotocol/express': minor
---
Add OAuth Resource-Server glue to the Express adapter: `requireBearerAuth` middleware (token verification + RFC 6750 `WWW-Authenticate` challenges), `mcpAuthMetadataRouter` (serves RFC 9728 Protected Resource Metadata and mirrors RFC 8414 AS metadata at the resource origin), the `getOAuthProtectedResourceMetadataUrl` helper, and the `OAuthTokenVerifier` interface. These restore the v1 `src/server/auth` Resource-Server pieces as first-class v2 API so MCP servers can plug into an external Authorization Server with a few lines of Express wiring.

View file

@ -69,9 +69,9 @@ For production use, you can either:
The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/server/README.md`](../examples/server/README.md).
### Why did we remove `server` auth exports?
### Where are the server auth helpers?
Server authentication & authorization is outside of the scope of the SDK, and the recommendation is to use packages that focus on this area specifically (or a full-fledged Authorization Server for those who use such). Example packages provide an example with `better-auth`.
Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. Example packages provide a demo with `better-auth`.
### Why did we remove `server` SSE transport?

View file

@ -54,7 +54,7 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table.
| `@modelcontextprotocol/sdk/server/stdio.js` | `@modelcontextprotocol/server` |
| `@modelcontextprotocol/sdk/server/streamableHttp.js` | `@modelcontextprotocol/node` (class renamed to `NodeStreamableHTTPServerTransport`) OR `@modelcontextprotocol/server` (web-standard `WebStandardStreamableHTTPServerTransport` for Cloudflare Workers, Deno, etc.) |
| `@modelcontextprotocol/sdk/server/sse.js` | REMOVED (migrate to Streamable HTTP) |
| `@modelcontextprotocol/sdk/server/auth/*` | REMOVED (use external auth library) |
| `@modelcontextprotocol/sdk/server/auth/*` | RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers removed (use external IdP/OAuth library) |
| `@modelcontextprotocol/sdk/server/middleware.js` | `@modelcontextprotocol/express` (signature changed, see section 8) |
### Types / shared imports
@ -319,8 +319,7 @@ new URL(ctx.http?.req?.url).searchParams.get('debug')
### Server-side auth
All server OAuth exports removed: `mcpAuthRouter`, `OAuthServerProvider`, `OAuthTokenVerifier`, `requireBearerAuth`, `authenticateClient`, `ProxyOAuthServerProvider`, `allowedMethods`, and associated types. Use an external auth library (e.g., `better-auth`). See
`examples/server/src/` for demos.
Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) are removed from the core SDK; use an external IdP/OAuth library. See `examples/server/src/` for demos.
### Host header validation (Express)
@ -502,6 +501,6 @@ Access validators explicitly:
6. Replace plain header objects with `new Headers({...})` and bracket access (`headers['x']`) with `.get()` calls per section 7
7. If using `hostHeaderValidation` from server, update import and signature per section 8
8. If using server SSE transport, migrate to Streamable HTTP
9. If using server auth from the SDK, migrate to an external auth library
9. If using server auth from the SDK: RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`) → `@modelcontextprotocol/express`; AS helpers → external IdP/OAuth library
10. If relying on `listTools()`/`listPrompts()`/etc. throwing on missing capabilities, set `enforceStrictCapabilities: true`
11. Verify: build with `tsc` / run tests

View file

@ -130,11 +130,11 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
```
### Server auth removed
### Server auth split
Server-side OAuth/auth has been removed entirely from the SDK. This includes `mcpAuthRouter`, `OAuthServerProvider`, `OAuthTokenVerifier`, `requireBearerAuth`, `authenticateClient`, `ProxyOAuthServerProvider`, `allowedMethods`, and all associated types.
Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are now first-class in `@modelcontextprotocol/express`.
Use a dedicated auth library (e.g., `better-auth`) or a full Authorization Server instead. See the [examples](../examples/server/src/) for a working demo with `better-auth`.
Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. See the [examples](../examples/server/src/) for a working demo with `better-auth`.
Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`.

View file

@ -29,6 +29,7 @@ pnpm tsx src/simpleStreamableHttp.ts
| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) |
| Resource-Server-only auth | Minimal OAuth RS using SDK's `mcpAuthMetadataRouter` + `requireBearerAuth` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) |
| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) |
| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) |
| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) |
@ -43,7 +44,6 @@ pnpm tsx src/simpleStreamableHttp.ts
```bash
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth --oauth-strict
```
## URL elicitation example (server + client)

View file

@ -9,13 +9,8 @@
import { randomUUID } from 'node:crypto';
import {
createProtectedResourceMetadataRouter,
getOAuthProtectedResourceMetadataUrl,
requireBearerAuth,
setupAuthServer
} from '@modelcontextprotocol/examples-shared';
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared';
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server';
import { isInitializeRequest, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server';
@ -235,7 +230,7 @@ let authMiddleware = null;
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: true });
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true });
// Add protected resource metadata route to the MCP server
// This allows clients to discover the auth server
@ -243,10 +238,9 @@ setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: t
app.use(createProtectedResourceMetadataRouter('/mcp'));
authMiddleware = requireBearerAuth({
verifier: demoTokenVerifier,
requiredScopes: [],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
strictResource: true,
expectedResource: mcpServerUrl
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
/**

View file

@ -0,0 +1,87 @@
/**
* Minimal Resource-Server-only auth using the SDK's RS helpers
* (`mcpAuthMetadataRouter`, `requireBearerAuth`, `OAuthTokenVerifier`).
*
* No better-auth. The Authorization Server is external; this example points
* its metadata at a placeholder issuer. For a full AS+RS setup with a real
* demo Authorization Server, see {@link ./simpleStreamableHttp.ts}.
*
* Run: pnpm tsx src/resourceServerOnly.ts
* Probe: curl http://localhost:3000/.well-known/oauth-protected-resource/mcp
* curl -H 'Authorization: Bearer demo-token' -X POST http://localhost:3000/mcp ...
*/
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth
} from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { AuthInfo, CallToolResult, OAuthMetadata } from '@modelcontextprotocol/server';
import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import type { Request, Response } from 'express';
import * as z from 'zod/v4';
const PORT = 3000;
const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`);
// In a real deployment this is your external Authorization Server's metadata
// (RFC 8414). The SDK router serves it verbatim at
// /.well-known/oauth-authorization-server so clients probing the RS origin
// can still discover the AS.
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']
};
// Replace with JWT verification, RFC 7662 introspection, etc.
const staticTokenVerifier: OAuthTokenVerifier = {
async verifyAccessToken(token): Promise<AuthInfo> {
if (token !== 'demo-token') {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token');
}
return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 };
}
};
const server = new McpServer({ name: 'rs-only', version: '1.0.0' }, { capabilities: {} });
server.registerTool(
'whoami',
{ description: 'Returns the authenticated subject.', inputSchema: z.object({}) },
async (_args, ctx): Promise<CallToolResult> => ({
content: [{ type: 'text', text: `client=${ctx.http?.authInfo?.clientId ?? 'anon'}` }]
})
);
const app = createMcpExpressApp();
app.use(
mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: mcpServerUrl,
resourceName: 'RS-only example'
})
);
const auth = requireBearerAuth({
verifier: staticTokenVerifier,
requiredScopes: ['mcp'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
app.post('/mcp', auth, async (req: Request, res: Response) => {
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => void transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(PORT, () => {
console.log(`RS-only MCP server on http://localhost:${PORT}/mcp`);
console.log(` PRM: ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`);
console.log(` AS metadata mirror: http://localhost:${PORT}/.well-known/oauth-authorization-server`);
});

View file

@ -1,12 +1,7 @@
import { randomUUID } from 'node:crypto';
import {
createProtectedResourceMetadataRouter,
getOAuthProtectedResourceMetadataUrl,
requireBearerAuth,
setupAuthServer
} from '@modelcontextprotocol/examples-shared';
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared';
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type {
CallToolResult,
@ -25,7 +20,6 @@ import { InMemoryEventStore } from './inMemoryEventStore.js';
// Check for OAuth flag
const useOAuth = process.argv.includes('--oauth');
const strictOAuth = process.argv.includes('--oauth-strict');
const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled');
// Create shared task store for demonstration
@ -624,7 +618,7 @@ if (useOAuth) {
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth, demoMode: true, dangerousLoggingEnabled });
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, dangerousLoggingEnabled });
// Add protected resource metadata route to the MCP server
// This allows clients to discover the auth server
@ -632,10 +626,9 @@ if (useOAuth) {
app.use(createProtectedResourceMetadataRouter('/mcp'));
authMiddleware = requireBearerAuth({
verifier: demoTokenVerifier,
requiredScopes: [],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
strictResource: strictOAuth,
expectedResource: mcpServerUrl
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
}
@ -651,8 +644,8 @@ const mcpPostHandler = async (req: Request, res: Response) => {
console.log('Request body:', req.body);
}
if (useOAuth && req.app.locals.auth) {
console.log('Authenticated user:', req.app.locals.auth);
if (useOAuth && req.auth) {
console.log('Authenticated user:', req.auth);
}
try {
let transport: NodeStreamableHTTPServerTransport;
@ -742,8 +735,8 @@ const mcpGetHandler = async (req: Request, res: Response) => {
return;
}
if (useOAuth && req.app.locals.auth) {
console.log('Authenticated SSE connection from user:', req.app.locals.auth);
if (useOAuth && req.auth) {
console.log('Authenticated SSE connection from user:', req.auth);
}
// Check for Last-Event-ID header for resumability

View file

@ -1,95 +0,0 @@
/**
* Auth Middleware for MCP Demo Servers
*
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
*
* This provides bearer auth middleware for MCP servers.
*/
import type { NextFunction, Request, Response } from 'express';
import { verifyAccessToken } from './authServer.js';
export interface RequireBearerAuthOptions {
requiredScopes?: string[];
resourceMetadataUrl?: URL;
strictResource?: boolean;
expectedResource?: URL;
}
/**
* Express middleware that requires a valid Bearer token.
* Sets `req.app.locals.auth` on success.
*/
export function requireBearerAuth(
options: RequireBearerAuthOptions = {}
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
const { requiredScopes = [], resourceMetadataUrl, strictResource = false, expectedResource } = options;
// Build WWW-Authenticate header matching v1.x format
const buildWwwAuthHeader = (errorCode: string, message: string): string => {
let header = `Bearer error="${errorCode}", error_description="${message}"`;
if (requiredScopes.length > 0) {
header += `, scope="${requiredScopes.join(' ')}"`;
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${resourceMetadataUrl.toString()}"`;
}
return header;
};
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', 'Missing Authorization header'));
res.status(401).json({
error: 'invalid_token',
error_description: 'Missing Authorization header'
});
return;
}
const token = authHeader.slice(7); // Remove 'Bearer ' prefix
try {
const authInfo = await verifyAccessToken(token, {
strictResource,
expectedResource
});
// Check required scopes
if (requiredScopes.length > 0) {
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
if (!hasAllScopes) {
res.set('WWW-Authenticate', buildWwwAuthHeader('insufficient_scope', `Required scopes: ${requiredScopes.join(', ')}`));
res.status(403).json({
error: 'insufficient_scope',
error_description: `Required scopes: ${requiredScopes.join(', ')}`
});
return;
}
}
req.app.locals.auth = authInfo;
next();
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid token';
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', message));
res.status(401).json({
error: 'invalid_token',
error_description: message
});
}
};
}
/**
* Helper to get the protected resource metadata URL from a server URL.
*/
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): URL {
const metadataUrl = new URL(serverUrl);
// Insert well-known between host and path per RFC 9728 Section 3
metadataUrl.pathname = `/.well-known/oauth-protected-resource${serverUrl.pathname}`;
return metadataUrl;
}

View file

@ -9,6 +9,9 @@
* See: https://www.better-auth.com/docs/plugins/mcp
*/
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
import type { AuthInfo } from '@modelcontextprotocol/server';
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import { toNodeHandler } from 'better-auth/node';
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
import cors from 'cors';
@ -21,7 +24,6 @@ import { createDemoAuth, DEMO_USER_CREDENTIALS } from './auth.js';
export interface SetupAuthServerOptions {
authServerUrl: URL;
mcpServerUrl: URL;
strictResource?: boolean;
/**
* Examples should be used for **demo** only and not for production purposes, however this mode disables some logging and other features.
*/
@ -284,60 +286,29 @@ export function createProtectedResourceMetadataRouter(resourcePath = '/mcp'): Ro
}
/**
* Verifies an access token using better-auth's getMcpSession.
* This can be used by MCP servers to validate tokens.
* Demo {@link OAuthTokenVerifier} backed by better-auth's `getMcpSession`.
* Pass this to `requireBearerAuth({ verifier: demoTokenVerifier, ... })` from
* `@modelcontextprotocol/express` to validate Bearer tokens against the demo
* Authorization Server started by `setupAuthServer`.
*/
export async function verifyAccessToken(
token: string,
options?: { strictResource?: boolean; expectedResource?: URL }
): Promise<{
token: string;
clientId: string;
scopes: string[];
expiresAt: number;
}> {
const auth = getAuth();
export const demoTokenVerifier: OAuthTokenVerifier = {
async verifyAccessToken(token: string): Promise<AuthInfo> {
const auth = getAuth();
try {
// Create a mock request with the Authorization header
const headers = new Headers();
headers.set('Authorization', `Bearer ${token}`);
// Use better-auth's getMcpSession API
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = await (auth.api as any).getMcpSession({
headers
});
const session = await (auth.api as any).getMcpSession({ headers });
if (!session) {
throw new Error('Invalid token');
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid token');
}
// OAuthAccessToken has:
// - accessToken, refreshToken: string
// - accessTokenExpiresAt, refreshTokenExpiresAt: Date
// - clientId, userId: string
// - scopes: string (space-separated)
const scopes = typeof session.scopes === 'string' ? session.scopes.split(' ') : ['openid'];
const expiresAt = session.accessTokenExpiresAt
? Math.floor(new Date(session.accessTokenExpiresAt).getTime() / 1000)
: Math.floor(Date.now() / 1000) + 3600;
// Note: better-auth's OAuthAccessToken doesn't have a resource field
// Resource validation would need to be done at a different layer
if (options?.strictResource && options.expectedResource) {
// For now, we skip resource validation as it's not in the session
// In production, you'd store and validate this separately
console.warn('[Auth] Resource validation requested but not available in better-auth session');
}
return {
token,
clientId: session.clientId,
scopes,
expiresAt
};
} catch (error) {
throw new Error(`Token verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
return { token, clientId: session.clientId, scopes, expiresAt };
}
}
};

View file

@ -2,10 +2,6 @@
export type { CreateDemoAuthOptions, DemoAuth } from './auth.js';
export { createDemoAuth } from './auth.js';
// Auth middleware
export type { RequireBearerAuthOptions } from './authMiddleware.js';
export { getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from './authMiddleware.js';
// Auth server setup
// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express)
export type { SetupAuthServerOptions } from './authServer.js';
export { createProtectedResourceMetadataRouter, getAuth, setupAuthServer, verifyAccessToken } from './authServer.js';
export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js';

View file

@ -8,6 +8,7 @@
"paths": {
"*": ["./*"],
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
"@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"],
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
"@modelcontextprotocol/core": [
"./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts"

View file

@ -8,6 +8,8 @@ It does **not** implement MCP itself. Instead, it helps you:
- create an Express app with sensible defaults for MCP servers
- add DNS rebinding protection via Host header validation (recommended for localhost servers)
- protect routes with `requireBearerAuth` (validates `Authorization: Bearer …` via your `OAuthTokenVerifier`)
- serve OAuth Protected Resource Metadata (RFC 9728) via `mcpAuthMetadataRouter`
## Install
@ -23,6 +25,10 @@ npm install @modelcontextprotocol/node
- `createMcpExpressApp(options?)`
- `hostHeaderValidation(allowedHostnames)`
- `localhostHostValidation()`
- `requireBearerAuth(options)`
- `mcpAuthMetadataRouter(options)`
- `getOAuthProtectedResourceMetadataUrl(serverUrl)`
- `OAuthTokenVerifier` (interface)
## Usage

View file

@ -42,10 +42,12 @@
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {},
"dependencies": {
"cors": "catalog:runtimeServerOnly"
},
"peerDependencies": {
"@modelcontextprotocol/server": "workspace:^",
"express": "catalog:runtimeServerOnly"
"express": "^4.18.0 || ^5.0.0"
},
"devDependencies": {
"@modelcontextprotocol/server": "workspace:^",
@ -53,8 +55,11 @@
"@modelcontextprotocol/vitest-config": "workspace:^",
"@modelcontextprotocol/eslint-config": "workspace:^",
"@eslint/js": "catalog:devTools",
"@types/cors": "catalog:devTools",
"@types/express": "catalog:devTools",
"@types/express-serve-static-core": "catalog:devTools",
"@types/supertest": "catalog:devTools",
"supertest": "catalog:devTools",
"@typescript/native-preview": "catalog:devTools",
"eslint": "catalog:devTools",
"eslint-config-prettier": "catalog:devTools",

View file

@ -0,0 +1,120 @@
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import type { RequestHandler } from 'express';
import type { OAuthTokenVerifier } from './types.js';
/**
* Options for {@link requireBearerAuth}.
*/
export interface BearerAuthMiddlewareOptions {
/**
* A verifier used to validate access tokens.
*/
verifier: OAuthTokenVerifier;
/**
* Optional scopes that the token must have. When any are missing the
* middleware responds with `403 insufficient_scope`.
*/
requiredScopes?: string[];
/**
* Optional Protected Resource Metadata URL to advertise in the
* `WWW-Authenticate` header on 401/403 responses, per
* {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}.
*
* Typically built with `getOAuthProtectedResourceMetadataUrl`.
*/
resourceMetadataUrl?: string;
}
function buildWwwAuthenticateHeader(
errorCode: string,
description: string,
requiredScopes: string[],
resourceMetadataUrl: string | undefined
): string {
let header = `Bearer error="${errorCode}", error_description="${description}"`;
if (requiredScopes.length > 0) {
header += `, scope="${requiredScopes.join(' ')}"`;
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${resourceMetadataUrl}"`;
}
return header;
}
/**
* Express middleware that requires a valid Bearer token in the `Authorization`
* header.
*
* The token is validated via the supplied {@link OAuthTokenVerifier} and the
* resulting `AuthInfo` (from `@modelcontextprotocol/server`) is attached
* to `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and
* surfaces it to handlers as `ctx.http.authInfo`.
*
* On failure the middleware sends a JSON OAuth error body and a
* `WWW-Authenticate: Bearer …` challenge that includes the configured
* `resource_metadata` URL so clients can discover the Authorization Server.
*/
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header');
}
const [type, token] = authHeader.split(' ');
if (type?.toLowerCase() !== 'bearer' || !token) {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'");
}
const authInfo = await verifier.verifyAccessToken(token);
// Check if token has the required scopes (if any)
if (requiredScopes.length > 0) {
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
if (!hasAllScopes) {
throw new OAuthError(OAuthErrorCode.InsufficientScope, 'Insufficient scope');
}
}
// Check if the token is set to expire or if it is expired
if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has no expiration time');
} else if (authInfo.expiresAt < Date.now() / 1000) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired');
}
req.auth = authInfo;
next();
} catch (error) {
if (error instanceof OAuthError) {
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
switch (error.code) {
case OAuthErrorCode.InvalidToken: {
res.set('WWW-Authenticate', challenge);
res.status(401).json(error.toResponseObject());
break;
}
case OAuthErrorCode.InsufficientScope: {
res.set('WWW-Authenticate', challenge);
res.status(403).json(error.toResponseObject());
break;
}
case OAuthErrorCode.ServerError: {
res.status(500).json(error.toResponseObject());
break;
}
default: {
res.status(400).json(error.toResponseObject());
}
}
} else {
const serverError = new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
res.status(500).json(serverError.toResponseObject());
}
}
};
}

View file

@ -0,0 +1,153 @@
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/server';
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import cors from 'cors';
import type { RequestHandler, Router } from 'express';
import express from 'express';
// Dev-only escape hatch: allow http:// issuer URLs (e.g., for local testing).
const allowInsecureIssuerUrl =
process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === 'true' || process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === '1';
if (allowInsecureIssuerUrl) {
// eslint-disable-next-line no-console
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
* {@link metadataHandler} to restrict metadata endpoints to GET/OPTIONS.
*/
export function allowedMethods(allowed: string[]): RequestHandler {
return (req, res, next) => {
if (allowed.includes(req.method)) {
next();
return;
}
const error = new OAuthError(OAuthErrorCode.MethodNotAllowed, `The method ${req.method} is not allowed for this endpoint`);
res.status(405).set('Allow', allowed.join(', ')).json(error.toResponseObject());
};
}
/**
* Builds a small Express router that serves the given OAuth metadata document
* at `/` as JSON, with permissive CORS and a GET/OPTIONS method allow-list.
*
* Used by {@link mcpAuthMetadataRouter} for both the Authorization Server and
* Protected Resource metadata endpoints.
*/
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
const router = express.Router();
// Metadata documents must be fetchable from web-based MCP clients on any origin.
router.use(cors());
router.use(allowedMethods(['GET', 'OPTIONS']));
router.get('/', (_req, res) => {
res.status(200).json(metadata);
});
return router;
}
/**
* Options for {@link mcpAuthMetadataRouter}.
*/
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;
}
/**
* Builds an Express router that serves the two OAuth discovery documents an
* MCP server acting purely as a Resource Server needs to expose:
*
* - `/.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}.
*
* Mount this router at the application root:
*
* ```ts
* app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl }));
* ```
*
* Pair with `requireBearerAuth` on your `/mcp` route and pass
* `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));
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));
// Mirror the AS metadata at this origin for clients that look here first.
router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata));
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;
}

View file

@ -0,0 +1,37 @@
import type { AuthInfo } from '@modelcontextprotocol/server';
/**
* Minimal token-verifier interface for MCP servers acting as an OAuth 2.0
* Resource Server. Implementations introspect or locally validate an access
* token and return the resulting {@link AuthInfo}, which is then attached to
* the Express request and surfaced to MCP request handlers via
* `ctx.http.authInfo`.
*
* This is intentionally narrower than a full OAuth Authorization Server
* provider — it only covers the verification step a Resource Server needs.
*/
export interface OAuthTokenVerifier {
/**
* Verifies an access token and returns information about it.
*
* Implementations should throw an `OAuthError` (from `@modelcontextprotocol/server`)
* with `OAuthErrorCode.InvalidToken` when
* the token is unknown, revoked, or otherwise invalid; `requireBearerAuth`
* maps that to a 401 with a `WWW-Authenticate` challenge.
*
* Note: `requireBearerAuth` rejects tokens whose `AuthInfo.expiresAt` is unset
* (matches v1 behavior). Ensure your verifier populates it (e.g. from RFC 7662
* introspection `exp` or the JWT `exp` claim).
*/
verifyAccessToken(token: string): Promise<AuthInfo>;
}
declare module 'express-serve-static-core' {
interface Request {
/**
* Information about the validated access token, populated by
* `requireBearerAuth`.
*/
auth?: AuthInfo;
}
}

View file

@ -1,2 +1,9 @@
export * from './express.js';
export * from './middleware/hostHeaderValidation.js';
// OAuth Resource-Server glue: bearer-token middleware + PRM/AS metadata router.
export type { BearerAuthMiddlewareOptions } from './auth/bearerAuth.js';
export { requireBearerAuth } from './auth/bearerAuth.js';
export type { AuthMetadataOptions } from './auth/metadataRouter.js';
export { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from './auth/metadataRouter.js';
export type { OAuthTokenVerifier } from './auth/types.js';

View file

@ -0,0 +1,218 @@
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import type { Request, Response } from 'express';
import express from 'express';
import supertest from 'supertest';
import type { Mock } from 'vitest';
import { vi } from 'vitest';
import type { OAuthTokenVerifier } from '../../src/auth/types.js';
import { requireBearerAuth } from '../../src/auth/bearerAuth.js';
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../src/auth/metadataRouter.js';
// ---------------------------------------------------------------------------
// requireBearerAuth
// ---------------------------------------------------------------------------
const mockVerifyAccessToken = vi.fn();
const mockVerifier: OAuthTokenVerifier = { verifyAccessToken: mockVerifyAccessToken };
function createMockReqResNext(authorization?: string) {
const req = { headers: { authorization } } as Request;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis()
} as unknown as Response;
const next = vi.fn() as Mock;
return { req, res, next };
}
describe('requireBearerAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('attaches AuthInfo to req.auth and calls next on a valid token', async () => {
const validAuthInfo: AuthInfo = {
token: 'valid-token',
clientId: 'client-123',
scopes: ['read', 'write'],
expiresAt: Math.floor(Date.now() / 1000) + 3600
};
mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
const { req, res, next } = createMockReqResNext('Bearer valid-token');
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(req, res, next);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
expect(req.auth).toEqual(validAuthInfo);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('responds 401 with WWW-Authenticate (incl. resource_metadata) when header is missing', async () => {
const { req, res, next } = createMockReqResNext(undefined);
const middleware = requireBearerAuth({
verifier: mockVerifier,
resourceMetadataUrl: 'https://api.example.com/.well-known/oauth-protected-resource'
});
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.set).toHaveBeenCalledWith(
'WWW-Authenticate',
expect.stringMatching(
/^Bearer error="invalid_token".*resource_metadata="https:\/\/api\.example\.com\/\.well-known\/oauth-protected-resource"$/
)
);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token' }));
expect(next).not.toHaveBeenCalled();
});
it('responds 401 when the verifier throws InvalidToken', async () => {
mockVerifyAccessToken.mockRejectedValue(new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'));
const { req, res, next } = createMockReqResNext('Bearer nope');
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token', error_description: 'unknown token' }));
expect(next).not.toHaveBeenCalled();
});
it('responds 401 when the token is expired', async () => {
mockVerifyAccessToken.mockResolvedValue({
token: 'expired',
clientId: 'client-123',
scopes: [],
expiresAt: Math.floor(Date.now() / 1000) - 100
} satisfies AuthInfo);
const { req, res, next } = createMockReqResNext('Bearer expired');
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token', error_description: 'Token has expired' }));
expect(next).not.toHaveBeenCalled();
});
it('responds 403 with scope in WWW-Authenticate when required scopes are missing', async () => {
mockVerifyAccessToken.mockResolvedValue({
token: 'valid',
clientId: 'client-123',
scopes: ['read'],
expiresAt: Math.floor(Date.now() / 1000) + 3600
} satisfies AuthInfo);
const { req, res, next } = createMockReqResNext('Bearer valid');
const middleware = requireBearerAuth({ verifier: mockVerifier, requiredScopes: ['read', 'write'] });
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('scope="read write"'));
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'insufficient_scope' }));
expect(next).not.toHaveBeenCalled();
});
it('responds 500 when the verifier throws a non-OAuth error', async () => {
mockVerifyAccessToken.mockRejectedValue(new Error('boom'));
const { req, res, next } = createMockReqResNext('Bearer valid');
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'server_error' }));
expect(next).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// mcpAuthMetadataRouter + getOAuthProtectedResourceMetadataUrl
// ---------------------------------------------------------------------------
describe('mcpAuthMetadataRouter', () => {
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']
};
it('serves PRM and AS metadata at the well-known endpoints', async () => {
const app = express();
app.use(
mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: new URL('https://api.example.com/'),
scopesSupported: ['read', 'write'],
resourceName: 'Test API',
serviceDocumentationUrl: new URL('https://docs.example.com/')
})
);
const prm = await supertest(app).get('/.well-known/oauth-protected-resource');
expect(prm.status).toBe(200);
expect(prm.body.resource).toBe('https://api.example.com/');
expect(prm.body.authorization_servers).toEqual(['https://auth.example.com/']);
expect(prm.body.scopes_supported).toEqual(['read', 'write']);
expect(prm.body.resource_name).toBe('Test API');
expect(prm.body.resource_documentation).toBe('https://docs.example.com/');
const as = await supertest(app).get('/.well-known/oauth-authorization-server');
expect(as.status).toBe(200);
expect(as.body.issuer).toBe('https://auth.example.com/');
expect(as.body.token_endpoint).toBe('https://auth.example.com/token');
});
it('serves PRM at a path-aware route when resourceServerUrl has a path', async () => {
const app = express();
app.use(
mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: new URL('https://api.example.com/mcp')
})
);
const prm = await supertest(app).get('/.well-known/oauth-protected-resource/mcp');
expect(prm.status).toBe(200);
expect(prm.body.resource).toBe('https://api.example.com/mcp');
});
it('rejects non-GET methods on metadata endpoints with 405', async () => {
const app = express();
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: new URL('https://api.example.com/') }));
const res = await supertest(app).post('/.well-known/oauth-protected-resource');
expect(res.status).toBe(405);
expect(res.headers.allow).toBe('GET, OPTIONS');
expect(res.body.error).toBe('method_not_allowed');
});
it('rejects non-HTTPS issuer URLs', () => {
expect(() =>
mcpAuthMetadataRouter({
oauthMetadata: { ...oauthMetadata, issuer: 'http://auth.example.com/' },
resourceServerUrl: new URL('https://api.example.com/')
})
).toThrow('Issuer URL must be HTTPS');
});
});
describe('getOAuthProtectedResourceMetadataUrl', () => {
it('inserts the well-known prefix ahead of the path', () => {
expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))).toBe(
'https://api.example.com/.well-known/oauth-protected-resource/mcp'
);
});
it('drops a bare root path', () => {
expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/'))).toBe(
'https://api.example.com/.well-known/oauth-protected-resource'
);
});
});

14
pnpm-lock.yaml generated
View file

@ -650,8 +650,11 @@ importers:
packages/middleware/express:
dependencies:
express:
cors:
specifier: catalog:runtimeServerOnly
version: 2.8.6
express:
specifier: ^4.18.0 || ^5.0.0
version: 5.2.1
devDependencies:
'@eslint/js':
@ -669,12 +672,18 @@ importers:
'@modelcontextprotocol/vitest-config':
specifier: workspace:^
version: link:../../../common/vitest-config
'@types/cors':
specifier: catalog:devTools
version: 2.8.19
'@types/express':
specifier: catalog:devTools
version: 5.0.6
'@types/express-serve-static-core':
specifier: catalog:devTools
version: 5.1.1
'@types/supertest':
specifier: catalog:devTools
version: 6.0.3
'@typescript/native-preview':
specifier: catalog:devTools
version: 7.0.0-dev.20260327.2
@ -690,6 +699,9 @@ importers:
prettier:
specifier: catalog:devTools
version: 3.6.2
supertest:
specifier: catalog:devTools
version: 7.2.2
tsdown:
specifier: catalog:devTools
version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3)