|
| 1 | +/** |
| 2 | + * MCP Streamable HTTP server with an EXTERNAL OAuth Authorization Server. |
| 3 | + * |
| 4 | + * Demonstrates the production pattern from the MCP authorization spec where |
| 5 | + * the MCP server is a pure OAuth 2.0 *resource server* and a separate |
| 6 | + * Authorization Server (Auth0, Okta, Keycloak, Entra ID, AWS Cognito, your |
| 7 | + * in-house IdP, ...) mints the access tokens. The MCP server does **not** |
| 8 | + * know how to issue tokens — it validates incoming bearer tokens against the |
| 9 | + * AS's published JWKS, checks the audience (RFC 8707 resource indicator) and |
| 10 | + * scopes, and serves the resource. |
| 11 | + * |
| 12 | + * Contrast with `simpleStreamableHttp.ts --oauth`, which co-locates an AS and |
| 13 | + * the resource server in the same process for demos. |
| 14 | + * |
| 15 | + * Configure via environment variables: |
| 16 | + * MCP_JWKS_URL (required) e.g. https://<tenant>.auth0.com/.well-known/jwks.json |
| 17 | + * MCP_ISSUER (required) e.g. https://<tenant>.auth0.com/ |
| 18 | + * MCP_AUDIENCE (required) the resource indicator the AS binds to tokens (RFC 8707). |
| 19 | + * Typically the canonical MCP server URL. |
| 20 | + * MCP_AUTHORIZATION_SERVERS (optional, comma-separated) advertised in the |
| 21 | + * Protected Resource Metadata document |
| 22 | + * (RFC 9728). Defaults to MCP_ISSUER. |
| 23 | + * MCP_PORT (optional, default 3000) |
| 24 | + * |
| 25 | + * Quick local sketch with Auth0: |
| 26 | + * export MCP_JWKS_URL=https://example.auth0.com/.well-known/jwks.json |
| 27 | + * export MCP_ISSUER=https://example.auth0.com/ |
| 28 | + * export MCP_AUDIENCE=http://localhost:3000/mcp |
| 29 | + * pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthStreamableHttp.ts |
| 30 | + * |
| 31 | + * Tools registered: |
| 32 | + * - `whoami` requires `mcp:read` |
| 33 | + * - `echo` requires `mcp:write` |
| 34 | + */ |
| 35 | + |
| 36 | +import { randomUUID } from 'node:crypto'; |
| 37 | + |
| 38 | +import { createMcpExpressApp } from '@modelcontextprotocol/express'; |
| 39 | +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; |
| 40 | +import type { AuthInfo, CallToolResult } from '@modelcontextprotocol/server'; |
| 41 | +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; |
| 42 | +import cors from 'cors'; |
| 43 | +import type { NextFunction, Request, Response } from 'express'; |
| 44 | +import type { JWTPayload } from 'jose'; |
| 45 | +import { createRemoteJWKSet, jwtVerify } from 'jose'; |
| 46 | +import * as z from 'zod/v4'; |
| 47 | + |
| 48 | +// --- Config ----------------------------------------------------------------- |
| 49 | + |
| 50 | +const JWKS_URL = process.env.MCP_JWKS_URL; |
| 51 | +const ISSUER = process.env.MCP_ISSUER; |
| 52 | +const AUDIENCE = process.env.MCP_AUDIENCE; |
| 53 | +const AUTHORIZATION_SERVERS = (process.env.MCP_AUTHORIZATION_SERVERS ?? ISSUER ?? '') |
| 54 | + .split(',') |
| 55 | + .map(s => s.trim()) |
| 56 | + .filter(Boolean); |
| 57 | +const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; |
| 58 | + |
| 59 | +if (!JWKS_URL || !ISSUER || !AUDIENCE) { |
| 60 | + console.error('Missing required env: MCP_JWKS_URL, MCP_ISSUER, MCP_AUDIENCE.'); |
| 61 | + console.error('See the file header comment for an example configuration.'); |
| 62 | + // eslint-disable-next-line unicorn/no-process-exit |
| 63 | + process.exit(1); |
| 64 | +} |
| 65 | + |
| 66 | +// RFC 9728 §5.1: the metadata location for resource `https://host/mcp` is |
| 67 | +// `https://host/.well-known/oauth-protected-resource/mcp`. We derive both the |
| 68 | +// path served on this app and the absolute URL advertised in WWW-Authenticate |
| 69 | +// from the configured audience so they line up with whatever the AS actually |
| 70 | +// bound the token to. |
| 71 | +const AUDIENCE_URL = new URL(AUDIENCE); |
| 72 | +const METADATA_PATH = `/.well-known/oauth-protected-resource${AUDIENCE_URL.pathname === '/' ? '' : AUDIENCE_URL.pathname}`; |
| 73 | +const RESOURCE_METADATA_URL = new URL(METADATA_PATH, AUDIENCE_URL.origin); |
| 74 | + |
| 75 | +// --- JWKS bearer auth middleware ------------------------------------------- |
| 76 | + |
| 77 | +// `createRemoteJWKSet` caches keys and refreshes on `kid` rotation, so this is |
| 78 | +// safe to share across requests. |
| 79 | +const jwks = createRemoteJWKSet(new URL(JWKS_URL)); |
| 80 | + |
| 81 | +function parseScopes(payload: JWTPayload): string[] { |
| 82 | + // Common JWT scope claims: |
| 83 | + // - `scope` (RFC 8693): space-separated string |
| 84 | + // - `scp` (Okta/Entra): array of strings |
| 85 | + const raw = (payload as { scope?: unknown; scp?: unknown }).scope ?? (payload as { scp?: unknown }).scp; |
| 86 | + if (Array.isArray(raw)) return raw.map(String); |
| 87 | + if (typeof raw === 'string') return raw.split(/\s+/).filter(Boolean); |
| 88 | + return []; |
| 89 | +} |
| 90 | + |
| 91 | +function wwwAuthHeader(error: string, description: string, requiredScopes?: string[]): string { |
| 92 | + const parts = [ |
| 93 | + `Bearer error="${error}"`, |
| 94 | + `error_description="${description}"`, |
| 95 | + `resource_metadata="${RESOURCE_METADATA_URL.toString()}"` |
| 96 | + ]; |
| 97 | + if (requiredScopes && requiredScopes.length > 0) parts.push(`scope="${requiredScopes.join(' ')}"`); |
| 98 | + return parts.join(', '); |
| 99 | +} |
| 100 | + |
| 101 | +/** |
| 102 | + * Express middleware that validates a Bearer token against the configured |
| 103 | + * external Authorization Server. On success, attaches an `AuthInfo` to |
| 104 | + * `req.auth` so the SDK threads it into `ctx.http?.authInfo` for tool |
| 105 | + * handlers. On failure, replies with RFC 6750 401/403 plus a |
| 106 | + * `WWW-Authenticate` header that points to the resource metadata. |
| 107 | + */ |
| 108 | +function requireBearerAuth(requiredScopes: string[] = []) { |
| 109 | + return async ( |
| 110 | + req: Request & { auth?: AuthInfo }, |
| 111 | + res: Response, |
| 112 | + next: NextFunction |
| 113 | + ): Promise<void> => { |
| 114 | + const header = req.headers.authorization; |
| 115 | + if (!header || !header.startsWith('Bearer ')) { |
| 116 | + res.set('WWW-Authenticate', wwwAuthHeader('invalid_token', 'Missing Bearer token', requiredScopes)); |
| 117 | + res.status(401).json({ error: 'invalid_token', error_description: 'Missing Bearer token' }); |
| 118 | + return; |
| 119 | + } |
| 120 | + const token = header.slice('Bearer '.length).trim(); |
| 121 | + try { |
| 122 | + const { payload } = await jwtVerify(token, jwks, { |
| 123 | + issuer: ISSUER, |
| 124 | + audience: AUDIENCE |
| 125 | + }); |
| 126 | + const scopes = parseScopes(payload); |
| 127 | + |
| 128 | + // RFC 6750 §3.1: missing scopes -> 403 insufficient_scope. |
| 129 | + const missing = requiredScopes.filter(s => !scopes.includes(s)); |
| 130 | + if (missing.length > 0) { |
| 131 | + res.set( |
| 132 | + 'WWW-Authenticate', |
| 133 | + wwwAuthHeader('insufficient_scope', `Missing scopes: ${missing.join(' ')}`, requiredScopes) |
| 134 | + ); |
| 135 | + res.status(403).json({ |
| 136 | + error: 'insufficient_scope', |
| 137 | + error_description: `Missing scopes: ${missing.join(' ')}` |
| 138 | + }); |
| 139 | + return; |
| 140 | + } |
| 141 | + |
| 142 | + const authInfo: AuthInfo = { |
| 143 | + token, |
| 144 | + clientId: typeof payload.client_id === 'string' ? payload.client_id : (payload.azp as string | undefined) ?? '', |
| 145 | + scopes, |
| 146 | + expiresAt: typeof payload.exp === 'number' ? payload.exp : undefined, |
| 147 | + resource: AUDIENCE_URL, |
| 148 | + extra: { sub: payload.sub, iss: payload.iss } |
| 149 | + }; |
| 150 | + req.auth = authInfo; |
| 151 | + next(); |
| 152 | + } catch (error) { |
| 153 | + const message = error instanceof Error ? error.message : 'Token validation failed'; |
| 154 | + res.set('WWW-Authenticate', wwwAuthHeader('invalid_token', message, requiredScopes)); |
| 155 | + res.status(401).json({ error: 'invalid_token', error_description: message }); |
| 156 | + } |
| 157 | + }; |
| 158 | +} |
| 159 | + |
| 160 | +// --- MCP server ------------------------------------------------------------- |
| 161 | + |
| 162 | +const getServer = () => { |
| 163 | + const server = new McpServer( |
| 164 | + { name: 'external-auth-streamable-http-server', version: '1.0.0' }, |
| 165 | + { capabilities: { logging: {} } } |
| 166 | + ); |
| 167 | + |
| 168 | + // `whoami` — gated on `mcp:read`. Reads the validated AuthInfo that the |
| 169 | + // SDK propagates from `req.auth` into the tool context. |
| 170 | + server.registerTool( |
| 171 | + 'whoami', |
| 172 | + { |
| 173 | + title: 'Who Am I', |
| 174 | + description: 'Returns the authenticated subject and granted scopes (requires mcp:read).', |
| 175 | + inputSchema: z.object({}) |
| 176 | + }, |
| 177 | + async (_args, ctx): Promise<CallToolResult> => { |
| 178 | + const auth = ctx.http?.authInfo; |
| 179 | + return { |
| 180 | + content: [ |
| 181 | + { |
| 182 | + type: 'text', |
| 183 | + text: JSON.stringify( |
| 184 | + { |
| 185 | + sub: (auth?.extra?.sub as string | undefined) ?? null, |
| 186 | + clientId: auth?.clientId ?? null, |
| 187 | + scopes: auth?.scopes ?? [] |
| 188 | + }, |
| 189 | + null, |
| 190 | + 2 |
| 191 | + ) |
| 192 | + } |
| 193 | + ] |
| 194 | + }; |
| 195 | + } |
| 196 | + ); |
| 197 | + |
| 198 | + // `echo` — requires `mcp:write`. The tool itself re-checks the scope so |
| 199 | + // it stays correct even if a future maintainer wires it onto a route with |
| 200 | + // looser middleware. |
| 201 | + server.registerTool( |
| 202 | + 'echo', |
| 203 | + { |
| 204 | + title: 'Echo', |
| 205 | + description: 'Echoes the supplied message back (requires mcp:write).', |
| 206 | + inputSchema: z.object({ message: z.string().describe('Message to echo') }) |
| 207 | + }, |
| 208 | + async ({ message }, ctx): Promise<CallToolResult> => { |
| 209 | + const scopes = ctx.http?.authInfo?.scopes ?? []; |
| 210 | + if (!scopes.includes('mcp:write')) { |
| 211 | + return { |
| 212 | + isError: true, |
| 213 | + content: [{ type: 'text', text: 'Forbidden: mcp:write scope required.' }] |
| 214 | + }; |
| 215 | + } |
| 216 | + return { content: [{ type: 'text', text: message }] }; |
| 217 | + } |
| 218 | + ); |
| 219 | + |
| 220 | + return server; |
| 221 | +}; |
| 222 | + |
| 223 | +// --- Express app ------------------------------------------------------------ |
| 224 | + |
| 225 | +const app = createMcpExpressApp(); |
| 226 | + |
| 227 | +// Demo CORS — restrict in production. |
| 228 | +// WARNING: This configuration is for demo purposes only. In production, you |
| 229 | +// should restrict origins and configure CORS yourself. |
| 230 | +app.use( |
| 231 | + cors({ |
| 232 | + exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'], |
| 233 | + origin: '*' |
| 234 | + }) |
| 235 | +); |
| 236 | + |
| 237 | +// RFC 9728 Protected Resource Metadata. Clients fetch this on a 401 to |
| 238 | +// discover the authorization server(s) and supported scopes. |
| 239 | +app.get(METADATA_PATH, (_req: Request, res: Response) => { |
| 240 | + res.json({ |
| 241 | + resource: AUDIENCE, |
| 242 | + authorization_servers: AUTHORIZATION_SERVERS, |
| 243 | + bearer_methods_supported: ['header'], |
| 244 | + scopes_supported: ['mcp:read', 'mcp:write'], |
| 245 | + resource_documentation: 'https://modelcontextprotocol.io' |
| 246 | + }); |
| 247 | +}); |
| 248 | + |
| 249 | +// All `/mcp` routes require at least `mcp:read`. The `echo` tool re-checks |
| 250 | +// `mcp:write` inline (see above) so the authorization story stays clear. |
| 251 | +const authReadOnly = requireBearerAuth(['mcp:read']); |
| 252 | + |
| 253 | +const transports: Record<string, NodeStreamableHTTPServerTransport> = {}; |
| 254 | + |
| 255 | +const mcpPostHandler = async (req: Request, res: Response): Promise<void> => { |
| 256 | + const sessionId = req.headers['mcp-session-id'] as string | undefined; |
| 257 | + try { |
| 258 | + let transport: NodeStreamableHTTPServerTransport; |
| 259 | + if (sessionId && transports[sessionId]) { |
| 260 | + transport = transports[sessionId]; |
| 261 | + } else if (!sessionId && isInitializeRequest(req.body)) { |
| 262 | + transport = new NodeStreamableHTTPServerTransport({ |
| 263 | + sessionIdGenerator: () => randomUUID(), |
| 264 | + onsessioninitialized: sid => { |
| 265 | + transports[sid] = transport; |
| 266 | + } |
| 267 | + }); |
| 268 | + transport.onclose = () => { |
| 269 | + const sid = transport.sessionId; |
| 270 | + if (sid && transports[sid]) delete transports[sid]; |
| 271 | + }; |
| 272 | + const server = getServer(); |
| 273 | + await server.connect(transport); |
| 274 | + await transport.handleRequest(req, res, req.body); |
| 275 | + return; |
| 276 | + } else if (sessionId) { |
| 277 | + res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }); |
| 278 | + return; |
| 279 | + } else { |
| 280 | + res.status(400).json({ |
| 281 | + jsonrpc: '2.0', |
| 282 | + error: { code: -32_000, message: 'Bad Request: Session ID required' }, |
| 283 | + id: null |
| 284 | + }); |
| 285 | + return; |
| 286 | + } |
| 287 | + await transport.handleRequest(req, res, req.body); |
| 288 | + } catch (error) { |
| 289 | + console.error('Error handling MCP request:', error); |
| 290 | + if (!res.headersSent) { |
| 291 | + res.status(500).json({ |
| 292 | + jsonrpc: '2.0', |
| 293 | + error: { code: -32_603, message: 'Internal server error' }, |
| 294 | + id: null |
| 295 | + }); |
| 296 | + } |
| 297 | + } |
| 298 | +}; |
| 299 | + |
| 300 | +const mcpGetHandler = async (req: Request, res: Response): Promise<void> => { |
| 301 | + const sessionId = req.headers['mcp-session-id'] as string | undefined; |
| 302 | + if (!sessionId || !transports[sessionId]) { |
| 303 | + res.status(404).send('Session not found'); |
| 304 | + return; |
| 305 | + } |
| 306 | + await transports[sessionId].handleRequest(req, res); |
| 307 | +}; |
| 308 | + |
| 309 | +const mcpDeleteHandler = async (req: Request, res: Response): Promise<void> => { |
| 310 | + const sessionId = req.headers['mcp-session-id'] as string | undefined; |
| 311 | + if (!sessionId || !transports[sessionId]) { |
| 312 | + res.status(404).send('Session not found'); |
| 313 | + return; |
| 314 | + } |
| 315 | + await transports[sessionId].handleRequest(req, res); |
| 316 | +}; |
| 317 | + |
| 318 | +app.post('/mcp', authReadOnly, mcpPostHandler); |
| 319 | +app.get('/mcp', authReadOnly, mcpGetHandler); |
| 320 | +app.delete('/mcp', authReadOnly, mcpDeleteHandler); |
| 321 | + |
| 322 | +app.listen(MCP_PORT, error => { |
| 323 | + if (error) { |
| 324 | + console.error('Failed to start server:', error); |
| 325 | + // eslint-disable-next-line unicorn/no-process-exit |
| 326 | + process.exit(1); |
| 327 | + } |
| 328 | + console.log(`MCP (external-auth) Streamable HTTP Server listening on port ${MCP_PORT}`); |
| 329 | + console.log(` Issuer: ${ISSUER}`); |
| 330 | + console.log(` Audience: ${AUDIENCE}`); |
| 331 | + console.log(` JWKS: ${JWKS_URL}`); |
| 332 | + console.log(` Protected Resource Metadata: ${RESOURCE_METADATA_URL}`); |
| 333 | +}); |
| 334 | + |
| 335 | +process.on('SIGINT', async () => { |
| 336 | + console.log('Shutting down server...'); |
| 337 | + for (const sid of Object.keys(transports)) { |
| 338 | + try { |
| 339 | + await transports[sid]!.close(); |
| 340 | + delete transports[sid]; |
| 341 | + } catch (error) { |
| 342 | + console.error(`Error closing transport ${sid}:`, error); |
| 343 | + } |
| 344 | + } |
| 345 | + // eslint-disable-next-line unicorn/no-process-exit |
| 346 | + process.exit(0); |
| 347 | +}); |
0 commit comments