From 5d6bcaa6916988d9930f2f86d2e928791ae95d2c Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Sun, 26 Apr 2026 18:19:33 -0700 Subject: [PATCH] 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 0f684bec7..6b6cc6bdf 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 fcff95d9a..75713747c 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 000000000..65f07828c --- /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 0444ca9e3..5b6cc4ae8 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)