From 5d6bcaa6916988d9930f2f86d2e928791ae95d2c Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Sun, 26 Apr 2026 18:19:33 -0700 Subject: [PATCH 1/2] docs(examples): add external auth resource server example (closes #658) New `examples/server/src/externalAuthStreamableHttp.ts` shows the production OAuth pattern where the MCP server is a pure resource server that validates JWT bearer tokens minted by an external Authorization Server (Auth0, Okta, Keycloak, Entra ID, Cognito, in-house IdP, ...) via JWKS. RFC 8707 audience binding and RFC 9728 Protected Resource Metadata are demonstrated. No DIY OAuth server code is added; trust anchors come from environment variables. Also adds a row to `examples/server/README.md` and pulls `jose` from the existing `runtimeClientOnly` catalog into the examples-server package. --- examples/server/README.md | 26 ++ examples/server/package.json | 1 + .../server/src/externalAuthStreamableHttp.ts | 347 ++++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 377 insertions(+) create mode 100644 examples/server/src/externalAuthStreamableHttp.ts diff --git a/examples/server/README.md b/examples/server/README.md index 0f684bec7e..6b6cc6bdf4 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -39,6 +39,7 @@ pnpm tsx src/simpleStreamableHttp.ts | Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | | Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | | SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | +| External OAuth Authorization Server | Pure OAuth 2.0 resource server: validates JWT bearer tokens minted by an external AS via JWKS. | [`src/externalAuthStreamableHttp.ts`](src/externalAuthStreamableHttp.ts) | ## OAuth demo flags (Streamable HTTP server) @@ -46,6 +47,31 @@ pnpm tsx src/simpleStreamableHttp.ts pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth ``` +## External Authorization Server (resource-server pattern) + +`simpleStreamableHttp.ts --oauth` co-locates an Authorization Server with the +MCP server for demos. In production, the Authorization Server is usually a +separate system (Auth0, Okta, Keycloak, Entra ID, AWS Cognito, an in-house +IdP, ...) and the MCP server is a pure OAuth 2.0 *resource server* that +validates incoming bearer tokens. `externalAuthStreamableHttp.ts` shows that +pattern. + +The example reads its trust anchors from environment variables, validates +JWTs against the AS's published JWKS, enforces the RFC 8707 audience claim, +and serves RFC 9728 Protected Resource Metadata so clients can discover the +AS automatically: + +```bash +export MCP_JWKS_URL=https://.auth0.com/.well-known/jwks.json +export MCP_ISSUER=https://.auth0.com/ +export MCP_AUDIENCE=http://localhost:3000/mcp +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthStreamableHttp.ts +``` + +Tools registered: +- `whoami` — requires `mcp:read`. Echoes the validated subject and scopes. +- `echo` — requires `mcp:write`. Demonstrates per-tool scope enforcement. + ## URL elicitation example (server + client) Run the server: diff --git a/examples/server/package.json b/examples/server/package.json index fcff95d9a9..75713747c4 100644 --- a/examples/server/package.json +++ b/examples/server/package.json @@ -44,6 +44,7 @@ "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly", + "jose": "catalog:runtimeClientOnly", "valibot": "catalog:devTools", "zod": "catalog:runtimeShared" }, diff --git a/examples/server/src/externalAuthStreamableHttp.ts b/examples/server/src/externalAuthStreamableHttp.ts new file mode 100644 index 0000000000..65f07828c1 --- /dev/null +++ b/examples/server/src/externalAuthStreamableHttp.ts @@ -0,0 +1,347 @@ +/** + * MCP Streamable HTTP server with an EXTERNAL OAuth Authorization Server. + * + * Demonstrates the production pattern from the MCP authorization spec where + * the MCP server is a pure OAuth 2.0 *resource server* and a separate + * Authorization Server (Auth0, Okta, Keycloak, Entra ID, AWS Cognito, your + * in-house IdP, ...) mints the access tokens. The MCP server does **not** + * know how to issue tokens — it validates incoming bearer tokens against the + * AS's published JWKS, checks the audience (RFC 8707 resource indicator) and + * scopes, and serves the resource. + * + * Contrast with `simpleStreamableHttp.ts --oauth`, which co-locates an AS and + * the resource server in the same process for demos. + * + * Configure via environment variables: + * MCP_JWKS_URL (required) e.g. https://.auth0.com/.well-known/jwks.json + * MCP_ISSUER (required) e.g. https://.auth0.com/ + * MCP_AUDIENCE (required) the resource indicator the AS binds to tokens (RFC 8707). + * Typically the canonical MCP server URL. + * MCP_AUTHORIZATION_SERVERS (optional, comma-separated) advertised in the + * Protected Resource Metadata document + * (RFC 9728). Defaults to MCP_ISSUER. + * MCP_PORT (optional, default 3000) + * + * Quick local sketch with Auth0: + * export MCP_JWKS_URL=https://example.auth0.com/.well-known/jwks.json + * export MCP_ISSUER=https://example.auth0.com/ + * export MCP_AUDIENCE=http://localhost:3000/mcp + * pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthStreamableHttp.ts + * + * Tools registered: + * - `whoami` requires `mcp:read` + * - `echo` requires `mcp:write` + */ + +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { AuthInfo, CallToolResult } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { NextFunction, Request, Response } from 'express'; +import type { JWTPayload } from 'jose'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import * as z from 'zod/v4'; + +// --- Config ----------------------------------------------------------------- + +const JWKS_URL = process.env.MCP_JWKS_URL; +const ISSUER = process.env.MCP_ISSUER; +const AUDIENCE = process.env.MCP_AUDIENCE; +const AUTHORIZATION_SERVERS = (process.env.MCP_AUTHORIZATION_SERVERS ?? ISSUER ?? '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); +const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; + +if (!JWKS_URL || !ISSUER || !AUDIENCE) { + console.error('Missing required env: MCP_JWKS_URL, MCP_ISSUER, MCP_AUDIENCE.'); + console.error('See the file header comment for an example configuration.'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} + +// RFC 9728 §5.1: the metadata location for resource `https://host/mcp` is +// `https://host/.well-known/oauth-protected-resource/mcp`. We derive both the +// path served on this app and the absolute URL advertised in WWW-Authenticate +// from the configured audience so they line up with whatever the AS actually +// bound the token to. +const AUDIENCE_URL = new URL(AUDIENCE); +const METADATA_PATH = `/.well-known/oauth-protected-resource${AUDIENCE_URL.pathname === '/' ? '' : AUDIENCE_URL.pathname}`; +const RESOURCE_METADATA_URL = new URL(METADATA_PATH, AUDIENCE_URL.origin); + +// --- JWKS bearer auth middleware ------------------------------------------- + +// `createRemoteJWKSet` caches keys and refreshes on `kid` rotation, so this is +// safe to share across requests. +const jwks = createRemoteJWKSet(new URL(JWKS_URL)); + +function parseScopes(payload: JWTPayload): string[] { + // Common JWT scope claims: + // - `scope` (RFC 8693): space-separated string + // - `scp` (Okta/Entra): array of strings + const raw = (payload as { scope?: unknown; scp?: unknown }).scope ?? (payload as { scp?: unknown }).scp; + if (Array.isArray(raw)) return raw.map(String); + if (typeof raw === 'string') return raw.split(/\s+/).filter(Boolean); + return []; +} + +function wwwAuthHeader(error: string, description: string, requiredScopes?: string[]): string { + const parts = [ + `Bearer error="${error}"`, + `error_description="${description}"`, + `resource_metadata="${RESOURCE_METADATA_URL.toString()}"` + ]; + if (requiredScopes && requiredScopes.length > 0) parts.push(`scope="${requiredScopes.join(' ')}"`); + return parts.join(', '); +} + +/** + * Express middleware that validates a Bearer token against the configured + * external Authorization Server. On success, attaches an `AuthInfo` to + * `req.auth` so the SDK threads it into `ctx.http?.authInfo` for tool + * handlers. On failure, replies with RFC 6750 401/403 plus a + * `WWW-Authenticate` header that points to the resource metadata. + */ +function requireBearerAuth(requiredScopes: string[] = []) { + return async ( + req: Request & { auth?: AuthInfo }, + res: Response, + next: NextFunction + ): Promise => { + const header = req.headers.authorization; + if (!header || !header.startsWith('Bearer ')) { + res.set('WWW-Authenticate', wwwAuthHeader('invalid_token', 'Missing Bearer token', requiredScopes)); + res.status(401).json({ error: 'invalid_token', error_description: 'Missing Bearer token' }); + return; + } + const token = header.slice('Bearer '.length).trim(); + try { + const { payload } = await jwtVerify(token, jwks, { + issuer: ISSUER, + audience: AUDIENCE + }); + const scopes = parseScopes(payload); + + // RFC 6750 §3.1: missing scopes -> 403 insufficient_scope. + const missing = requiredScopes.filter(s => !scopes.includes(s)); + if (missing.length > 0) { + res.set( + 'WWW-Authenticate', + wwwAuthHeader('insufficient_scope', `Missing scopes: ${missing.join(' ')}`, requiredScopes) + ); + res.status(403).json({ + error: 'insufficient_scope', + error_description: `Missing scopes: ${missing.join(' ')}` + }); + return; + } + + const authInfo: AuthInfo = { + token, + clientId: typeof payload.client_id === 'string' ? payload.client_id : (payload.azp as string | undefined) ?? '', + scopes, + expiresAt: typeof payload.exp === 'number' ? payload.exp : undefined, + resource: AUDIENCE_URL, + extra: { sub: payload.sub, iss: payload.iss } + }; + req.auth = authInfo; + next(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Token validation failed'; + res.set('WWW-Authenticate', wwwAuthHeader('invalid_token', message, requiredScopes)); + res.status(401).json({ error: 'invalid_token', error_description: message }); + } + }; +} + +// --- MCP server ------------------------------------------------------------- + +const getServer = () => { + const server = new McpServer( + { name: 'external-auth-streamable-http-server', version: '1.0.0' }, + { capabilities: { logging: {} } } + ); + + // `whoami` — gated on `mcp:read`. Reads the validated AuthInfo that the + // SDK propagates from `req.auth` into the tool context. + server.registerTool( + 'whoami', + { + title: 'Who Am I', + description: 'Returns the authenticated subject and granted scopes (requires mcp:read).', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const auth = ctx.http?.authInfo; + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + sub: (auth?.extra?.sub as string | undefined) ?? null, + clientId: auth?.clientId ?? null, + scopes: auth?.scopes ?? [] + }, + null, + 2 + ) + } + ] + }; + } + ); + + // `echo` — requires `mcp:write`. The tool itself re-checks the scope so + // it stays correct even if a future maintainer wires it onto a route with + // looser middleware. + server.registerTool( + 'echo', + { + title: 'Echo', + description: 'Echoes the supplied message back (requires mcp:write).', + inputSchema: z.object({ message: z.string().describe('Message to echo') }) + }, + async ({ message }, ctx): Promise => { + const scopes = ctx.http?.authInfo?.scopes ?? []; + if (!scopes.includes('mcp:write')) { + return { + isError: true, + content: [{ type: 'text', text: 'Forbidden: mcp:write scope required.' }] + }; + } + return { content: [{ type: 'text', text: message }] }; + } + ); + + return server; +}; + +// --- Express app ------------------------------------------------------------ + +const app = createMcpExpressApp(); + +// Demo CORS — restrict in production. +// WARNING: This configuration is for demo purposes only. In production, you +// should restrict origins and configure CORS yourself. +app.use( + cors({ + exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'], + origin: '*' + }) +); + +// RFC 9728 Protected Resource Metadata. Clients fetch this on a 401 to +// discover the authorization server(s) and supported scopes. +app.get(METADATA_PATH, (_req: Request, res: Response) => { + res.json({ + resource: AUDIENCE, + authorization_servers: AUTHORIZATION_SERVERS, + bearer_methods_supported: ['header'], + scopes_supported: ['mcp:read', 'mcp:write'], + resource_documentation: 'https://modelcontextprotocol.io' + }); +}); + +// All `/mcp` routes require at least `mcp:read`. The `echo` tool re-checks +// `mcp:write` inline (see above) so the authorization story stays clear. +const authReadOnly = requireBearerAuth(['mcp:read']); + +const transports: Record = {}; + +const mcpPostHandler = async (req: Request, res: Response): Promise => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + try { + let transport: NodeStreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sid => { + transports[sid] = transport; + } + }); + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) delete transports[sid]; + }; + const server = getServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else if (sessionId) { + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }); + return; + } + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32_603, message: 'Internal server error' }, + id: null + }); + } + } +}; + +const mcpGetHandler = async (req: Request, res: Response): Promise => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + await transports[sessionId].handleRequest(req, res); +}; + +const mcpDeleteHandler = async (req: Request, res: Response): Promise => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + await transports[sessionId].handleRequest(req, res); +}; + +app.post('/mcp', authReadOnly, mcpPostHandler); +app.get('/mcp', authReadOnly, mcpGetHandler); +app.delete('/mcp', authReadOnly, mcpDeleteHandler); + +app.listen(MCP_PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`MCP (external-auth) Streamable HTTP Server listening on port ${MCP_PORT}`); + console.log(` Issuer: ${ISSUER}`); + console.log(` Audience: ${AUDIENCE}`); + console.log(` JWKS: ${JWKS_URL}`); + console.log(` Protected Resource Metadata: ${RESOURCE_METADATA_URL}`); +}); + +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + for (const sid of Object.keys(transports)) { + try { + await transports[sid]!.close(); + delete transports[sid]; + } catch (error) { + console.error(`Error closing transport ${sid}:`, error); + } + } + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0444ca9e38..5b6cc4ae8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -372,6 +372,9 @@ importers: hono: specifier: catalog:runtimeServerOnly version: 4.12.9 + jose: + specifier: catalog:runtimeClientOnly + version: 6.2.2 valibot: specifier: catalog:devTools version: 1.3.1(typescript@5.9.3) From 96683ca34f1e249ec62050e54aab7bb8280ac936 Mon Sep 17 00:00:00 2001 From: mukunda katta Date: Thu, 30 Apr 2026 14:17:46 -0700 Subject: [PATCH 2/2] chore: format external auth example --- examples/server/README.md | 16 +++++----------- .../server/src/externalAuthStreamableHttp.ts | 18 ++++-------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/examples/server/README.md b/examples/server/README.md index 6b6cc6bdf4..9b74363558 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -49,17 +49,10 @@ pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamabl ## External Authorization Server (resource-server pattern) -`simpleStreamableHttp.ts --oauth` co-locates an Authorization Server with the -MCP server for demos. In production, the Authorization Server is usually a -separate system (Auth0, Okta, Keycloak, Entra ID, AWS Cognito, an in-house -IdP, ...) and the MCP server is a pure OAuth 2.0 *resource server* that -validates incoming bearer tokens. `externalAuthStreamableHttp.ts` shows that -pattern. - -The example reads its trust anchors from environment variables, validates -JWTs against the AS's published JWKS, enforces the RFC 8707 audience claim, -and serves RFC 9728 Protected Resource Metadata so clients can discover the -AS automatically: +`simpleStreamableHttp.ts --oauth` co-locates an Authorization Server with the MCP server for demos. In production, the Authorization Server is usually a separate system (Auth0, Okta, Keycloak, Entra ID, AWS Cognito, an in-house IdP, ...) and the MCP server is a pure OAuth 2.0 +_resource server_ that validates incoming bearer tokens. `externalAuthStreamableHttp.ts` shows that pattern. + +The example reads its trust anchors from environment variables, validates JWTs against the AS's published JWKS, enforces the RFC 8707 audience claim, and serves RFC 9728 Protected Resource Metadata so clients can discover the AS automatically: ```bash export MCP_JWKS_URL=https://.auth0.com/.well-known/jwks.json @@ -69,6 +62,7 @@ pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthStr ``` Tools registered: + - `whoami` — requires `mcp:read`. Echoes the validated subject and scopes. - `echo` — requires `mcp:write`. Demonstrates per-tool scope enforcement. diff --git a/examples/server/src/externalAuthStreamableHttp.ts b/examples/server/src/externalAuthStreamableHttp.ts index 65f07828c1..275c4a9ca6 100644 --- a/examples/server/src/externalAuthStreamableHttp.ts +++ b/examples/server/src/externalAuthStreamableHttp.ts @@ -106,11 +106,7 @@ function wwwAuthHeader(error: string, description: string, requiredScopes?: stri * `WWW-Authenticate` header that points to the resource metadata. */ function requireBearerAuth(requiredScopes: string[] = []) { - return async ( - req: Request & { auth?: AuthInfo }, - res: Response, - next: NextFunction - ): Promise => { + return async (req: Request & { auth?: AuthInfo }, res: Response, next: NextFunction): Promise => { const header = req.headers.authorization; if (!header || !header.startsWith('Bearer ')) { res.set('WWW-Authenticate', wwwAuthHeader('invalid_token', 'Missing Bearer token', requiredScopes)); @@ -128,10 +124,7 @@ function requireBearerAuth(requiredScopes: string[] = []) { // RFC 6750 §3.1: missing scopes -> 403 insufficient_scope. const missing = requiredScopes.filter(s => !scopes.includes(s)); if (missing.length > 0) { - res.set( - 'WWW-Authenticate', - wwwAuthHeader('insufficient_scope', `Missing scopes: ${missing.join(' ')}`, requiredScopes) - ); + res.set('WWW-Authenticate', wwwAuthHeader('insufficient_scope', `Missing scopes: ${missing.join(' ')}`, requiredScopes)); res.status(403).json({ error: 'insufficient_scope', error_description: `Missing scopes: ${missing.join(' ')}` @@ -141,7 +134,7 @@ function requireBearerAuth(requiredScopes: string[] = []) { const authInfo: AuthInfo = { token, - clientId: typeof payload.client_id === 'string' ? payload.client_id : (payload.azp as string | undefined) ?? '', + clientId: typeof payload.client_id === 'string' ? payload.client_id : ((payload.azp as string | undefined) ?? ''), scopes, expiresAt: typeof payload.exp === 'number' ? payload.exp : undefined, resource: AUDIENCE_URL, @@ -160,10 +153,7 @@ function requireBearerAuth(requiredScopes: string[] = []) { // --- MCP server ------------------------------------------------------------- const getServer = () => { - const server = new McpServer( - { name: 'external-auth-streamable-http-server', version: '1.0.0' }, - { capabilities: { logging: {} } } - ); + const server = new McpServer({ name: 'external-auth-streamable-http-server', version: '1.0.0' }, { capabilities: { logging: {} } }); // `whoami` — gated on `mcp:read`. Reads the validated AuthInfo that the // SDK propagates from `req.auth` into the tool context.