From 7ec3543fc2fb4183eb8d39fda114f22303309a01 Mon Sep 17 00:00:00 2001 From: Travis Bonnet Date: Mon, 16 Mar 2026 23:16:38 -0500 Subject: [PATCH 1/2] feat(examples): add external auth server example (RFC 8707) Adds a new example demonstrating MCP authentication with an external OAuth2 authorization server, as requested in #658. The example consists of three components: - External auth server: standalone OAuth2 AS that issues JWT tokens with JWKS endpoint for signature verification - MCP resource server: validates JWT tokens via JWKS, serves protected resource metadata pointing to the external AS - Client: discovers AS via resource metadata, authenticates, connects Implements RFC 8707 (resource indicators), RFC 9728 (protected resource metadata), RFC 9068 (JWT access tokens), RFC 7591 (dynamic client registration), and RFC 7636 (PKCE). Closes #658 --- .../client/src/externalAuthServerClient.ts | 203 ++++++++++++ examples/server/README.md | 1 + examples/server/package.json | 1 + .../server/src/externalAuthServer/README.md | 100 ++++++ .../src/externalAuthServer/authServer.ts | 296 +++++++++++++++++ .../src/externalAuthServer/resourceServer.ts | 307 ++++++++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 911 insertions(+) create mode 100644 examples/client/src/externalAuthServerClient.ts create mode 100644 examples/server/src/externalAuthServer/README.md create mode 100644 examples/server/src/externalAuthServer/authServer.ts create mode 100644 examples/server/src/externalAuthServer/resourceServer.ts diff --git a/examples/client/src/externalAuthServerClient.ts b/examples/client/src/externalAuthServerClient.ts new file mode 100644 index 000000000..dba5bb1ed --- /dev/null +++ b/examples/client/src/externalAuthServerClient.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +/** + * MCP Client for External Auth Server Example + * + * DEMO ONLY - NOT FOR PRODUCTION + * + * Connects to an MCP server that uses an external OAuth2 authorization server. + * Demonstrates the full OAuth flow: + * + * 1. Client connects to MCP server, receives 401 with resource metadata URL + * 2. Client fetches protected resource metadata to discover the external AS + * 3. Client fetches AS metadata (/.well-known/oauth-authorization-server) + * 4. Client dynamically registers with the AS + * 5. Client redirects user to AS for authorization (auto-approved in demo) + * 6. Client exchanges authorization code for JWT access token + * 7. Client connects to MCP server with the JWT Bearer token + * + * Usage: + * pnpm --filter @modelcontextprotocol/examples-client exec tsx src/externalAuthServerClient.ts [server-url] + */ + +import { createServer } from 'node:http'; + +import type { CallToolResult, ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; +import open from 'open'; + +import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; + +// --- Configuration --- + +const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; +const CALLBACK_PORT = 8090; +const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; + +// --- OAuth callback server --- + +async function waitForOAuthCallback(): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + if (req.url === '/favicon.ico') { + res.writeHead(404); + res.end(); + return; + } + + const parsedUrl = new URL(req.url || '', 'http://localhost'); + const code = parsedUrl.searchParams.get('code'); + const error = parsedUrl.searchParams.get('error'); + + if (code) { + console.log(`Authorization code received: ${code.slice(0, 10)}...`); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Successful!

+

You can close this window and return to the terminal.

+ + + + `); + resolve(code); + setTimeout(() => server.close(), 3000); + } else if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Failed

+

Error: ${error}

+ + + `); + reject(new Error(`OAuth authorization failed: ${error}`)); + } else { + res.writeHead(400); + res.end('Bad request'); + reject(new Error('No authorization code provided')); + } + }); + + server.listen(CALLBACK_PORT, () => { + console.log(`OAuth callback server listening on http://localhost:${CALLBACK_PORT}`); + }); + }); +} + +// --- Helpers --- + +async function openBrowser(url: string): Promise { + console.log(`Opening browser for authorization: ${url}`); + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + console.error(`Refusing to open URL with unsupported scheme: ${url}`); + return; + } + await open(url); + } catch { + console.log(`Please manually open: ${url}`); + } +} + +// --- Main --- + +async function main(): Promise { + const serverUrl = process.argv[2] || DEFAULT_SERVER_URL; + + console.log('MCP Client with External Auth Server'); + console.log(`Connecting to: ${serverUrl}`); + console.log(); + + // Set up OAuth client metadata for dynamic registration + const clientMetadata: OAuthClientMetadata = { + client_name: 'MCP External Auth Client', + redirect_uris: [CALLBACK_URL], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post' + }; + + // Create OAuth provider (handles token storage and redirect) + const oauthProvider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { + openBrowser(redirectUrl.toString()); + }); + + // Create MCP client + const client = new Client({ name: 'external-auth-client', version: '1.0.0' }, { capabilities: {} }); + + // Attempt connection with retry on auth challenge + async function attemptConnection(): Promise { + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: oauthProvider + }); + + try { + console.log('Attempting connection...'); + await client.connect(transport); + console.log('Connected successfully!'); + } catch (error) { + if (error instanceof UnauthorizedError) { + console.log('Authentication required. Starting OAuth flow with external AS...'); + const callbackPromise = waitForOAuthCallback(); + const authCode = await callbackPromise; + await transport.finishAuth(authCode); + console.log('Authorization complete. Reconnecting...'); + await attemptConnection(); + } else { + throw error; + } + } + } + + await attemptConnection(); + + // List available tools + console.log('\nListing available tools...'); + const toolsRequest: ListToolsRequest = { method: 'tools/list', params: {} }; + const toolsResult = await client.request(toolsRequest); + + if (toolsResult.tools && toolsResult.tools.length > 0) { + console.log('Available tools:'); + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description || '(no description)'}`); + } + } + + // Call the greet tool + console.log('\nCalling greet tool...'); + const greetResult = (await client.callTool({ name: 'greet', arguments: { name: 'World' } })) as CallToolResult; + for (const content of greetResult.content) { + if (content.type === 'text') { + console.log(` Result: ${content.text}`); + } + } + + // Call the whoami tool + console.log('\nCalling whoami tool...'); + const whoamiResult = (await client.callTool({ name: 'whoami', arguments: {} })) as CallToolResult; + for (const content of whoamiResult.content) { + if (content.type === 'text') { + console.log(` Result: ${content.text}`); + } + } + + console.log('\nDone! All authenticated calls succeeded.'); + process.exit(0); +} + +// Handle graceful shutdown +process.on('SIGINT', () => { + console.log('\nShutting down...'); + process.exit(0); +}); + +try { + await main(); +} catch (error) { + console.error('Error:', error); + process.exit(1); +} diff --git a/examples/server/README.md b/examples/server/README.md index 384e4f2c2..b7852776c 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -38,6 +38,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 auth server (RFC 8707) | MCP server + separate OAuth2 AS with JWT tokens and JWKS verification. | [`src/externalAuthServer/`](src/externalAuthServer/) | ## OAuth demo flags (Streamable HTTP 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/externalAuthServer/README.md b/examples/server/src/externalAuthServer/README.md new file mode 100644 index 000000000..e3a96dd47 --- /dev/null +++ b/examples/server/src/externalAuthServer/README.md @@ -0,0 +1,100 @@ +# External Auth Server Example + +Demonstrates MCP authentication using an **external OAuth2 authorization server** separate from the MCP resource server. This follows the RFC 8707 pattern where the authorization server (AS) and resource server (RS) are independent services. + +## Architecture + +``` +┌──────────┐ ┌────────────────────┐ ┌──────────────────────┐ +│ │ 1. 401 │ │ │ │ +│ Client │◄────────│ MCP Resource │ │ External OAuth AS │ +│ │ │ Server (:3000) │ │ (:3001) │ +│ │ │ │ │ │ +│ │ 2. Fetch protected resource │ │ - /authorize │ +│ │────────►│ metadata │ │ - /token │ +│ │◄────────│ (points to AS) │ │ - /register │ +│ │ │ │ │ - /jwks │ +│ │ 3. OAuth flow │ │ - /.well-known/ │ +│ │────────────────────────────────────────►│ oauth-authz-srv │ +│ │◄────────────────────────────────────────│ │ +│ │ │ │ │ │ +│ │ 4. MCP │ │ 5. JWT │ │ +│ │ + JWT │ │ verify │ │ +│ │────────►│ │────────►│ /jwks │ +│ │◄────────│ │◄────────│ │ +└──────────┘ └────────────────────┘ └──────────────────────┘ +``` + +## How it works + +1. Client connects to MCP server, gets a 401 with `resource_metadata` URL in the `WWW-Authenticate` header +2. Client fetches `/.well-known/oauth-protected-resource/mcp` from the MCP server +3. Protected resource metadata contains `authorization_servers: ["http://localhost:3001"]` +4. Client fetches `/.well-known/oauth-authorization-server` from the external AS +5. Client dynamically registers, gets redirected for authorization, exchanges code for JWT token +6. Client retries MCP connection with the JWT Bearer token +7. MCP server verifies the JWT signature via the AS's JWKS endpoint, checks issuer and audience + +## Key concepts + +- **RFC 9728 (Protected Resource Metadata)**: The MCP server advertises which authorization server(s) clients should use +- **RFC 8707 (Resource Indicators)**: The `resource` parameter binds tokens to a specific MCP server URL. The JWT `aud` claim is set to the MCP server's URL. +- **RFC 9068 (JWT Access Tokens)**: Tokens are self-contained JWTs, verified via JWKS without calling back to the AS +- **RFC 7591 (Dynamic Client Registration)**: Clients register themselves with the AS on first use + +## Running the example + +From the SDK root: + +```bash +# Terminal 1: Start the external authorization server +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthServer/authServer.ts + +# Terminal 2: Start the MCP resource server +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthServer/resourceServer.ts + +# Terminal 3: Run the client +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/externalAuthServerClient.ts +``` + +Or from within the example directories: + +```bash +# Terminal 1 +cd examples/server && pnpm tsx src/externalAuthServer/authServer.ts + +# Terminal 2 +cd examples/server && pnpm tsx src/externalAuthServer/resourceServer.ts + +# Terminal 3 +cd examples/client && pnpm tsx src/externalAuthServerClient.ts +``` + +## Environment variables + +| Variable | Default | Description | +| ----------------- | --------------------------- | ---------------------------------------------------------- | +| `AUTH_PORT` | `3001` | Port for the external authorization server | +| `MCP_PORT` | `3000` | Port for the MCP resource server | +| `AUTH_SERVER_URL` | `http://localhost:3001` | URL of the external AS (used by resource server) | +| `MCP_SERVER_URL` | `http://localhost:3000/mcp` | URL of the MCP resource (used by auth server for audience) | + +## Differences from simpleStreamableHttp --oauth + +The existing `simpleStreamableHttp.ts --oauth` example uses `better-auth` as a co-located auth server running inside the same process. This example demonstrates a fully **decoupled** architecture: + +| | simpleStreamableHttp --oauth | externalAuthServer | +| ------------------ | ------------------------------- | ------------------------- | +| Auth server | Co-located (better-auth) | Separate process | +| Token format | Opaque (better-auth session) | JWT (RFC 9068) | +| Token verification | Database lookup via better-auth | JWKS (no shared state) | +| Token binding | Session-based | Audience claim (RFC 8707) | +| Dependencies | better-auth, better-sqlite3 | jose (JWT/JWKS only) | + +## Extending this example + +- **Add token introspection**: Implement `/introspect` on the AS for opaque token support +- **Add token revocation**: Implement `/revoke` on the AS for logout flows +- **Add OIDC**: Extend the AS to return ID tokens alongside access tokens +- **Add scopes**: Check `scope` claims in the JWT for fine-grained access control +- **Production deployment**: Replace in-memory stores with a database, add real user authentication diff --git a/examples/server/src/externalAuthServer/authServer.ts b/examples/server/src/externalAuthServer/authServer.ts new file mode 100644 index 000000000..8f55db99a --- /dev/null +++ b/examples/server/src/externalAuthServer/authServer.ts @@ -0,0 +1,296 @@ +/** + * External OAuth2 Authorization Server + * + * DEMO ONLY - NOT FOR PRODUCTION + * + * A standalone OAuth2 authorization server that issues JWT access tokens. + * This demonstrates the "external AS" pattern from RFC 8707 where the + * authorization server is a separate service from the MCP resource server. + * + * Implements: + * - RFC 8414: OAuth 2.0 Authorization Server Metadata + * - RFC 7636: PKCE (Proof Key for Code Exchange) + * - RFC 7591: Dynamic Client Registration + * - RFC 9068: JWT Profile for OAuth 2.0 Access Tokens + * - RFC 8707: Resource Indicators (resource parameter) + * + * The MCP resource server verifies tokens using the JWKS endpoint. + */ + +import { randomBytes, randomUUID } from 'node:crypto'; + +import cors from 'cors'; +import express from 'express'; +import type { JWK } from 'jose'; +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; + +// --- Configuration --- + +const AUTH_PORT = process.env.AUTH_PORT ? Number.parseInt(process.env.AUTH_PORT, 10) : 3001; +const AUTH_SERVER_URL = `http://localhost:${AUTH_PORT}`; +const MCP_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; + +// --- Crypto: RSA key pair for signing JWTs --- + +const { publicKey, privateKey } = await generateKeyPair('RS256'); +const publicJwk: JWK = await exportJWK(publicKey); +const keyId = randomUUID(); +publicJwk.kid = keyId; +publicJwk.use = 'sig'; +publicJwk.alg = 'RS256'; + +// --- In-memory stores (demo only) --- + +interface RegisteredClient { + client_id: string; + client_secret?: string; + redirect_uris: string[]; + client_name?: string; + grant_types: string[]; + response_types: string[]; + token_endpoint_auth_method: string; +} + +interface AuthorizationCode { + code: string; + clientId: string; + redirectUri: string; + codeChallenge?: string; + codeChallengeMethod?: string; + scopes: string[]; + resource?: string; + expiresAt: number; +} + +const clients = new Map(); +const authorizationCodes = new Map(); + +// --- Express app --- + +const app = express(); +app.use(cors({ origin: '*' })); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// --- RFC 8414: Authorization Server Metadata --- + +app.get('/.well-known/oauth-authorization-server', (_req, res) => { + res.json({ + issuer: AUTH_SERVER_URL, + authorization_endpoint: `${AUTH_SERVER_URL}/authorize`, + token_endpoint: `${AUTH_SERVER_URL}/token`, + registration_endpoint: `${AUTH_SERVER_URL}/register`, + jwks_uri: `${AUTH_SERVER_URL}/jwks`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], + code_challenge_methods_supported: ['S256'], + scopes_supported: ['openid', 'profile', 'mcp:tools', 'mcp:resources'] + }); +}); + +// --- JWKS endpoint: public keys for token verification --- + +app.get('/jwks', (_req, res) => { + res.json({ + keys: [publicJwk] + }); +}); + +// --- RFC 7591: Dynamic Client Registration --- + +app.post('/register', (req, res) => { + const { redirect_uris, client_name, grant_types, response_types, token_endpoint_auth_method } = req.body; + + if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) { + res.status(400).json({ error: 'invalid_client_metadata', error_description: 'redirect_uris is required' }); + return; + } + + const clientId = randomUUID(); + const clientSecret = randomBytes(32).toString('base64url'); + + const client: RegisteredClient = { + client_id: clientId, + client_secret: clientSecret, + redirect_uris, + client_name: client_name || 'Unknown Client', + grant_types: grant_types || ['authorization_code'], + response_types: response_types || ['code'], + token_endpoint_auth_method: token_endpoint_auth_method || 'client_secret_post' + }; + + clients.set(clientId, client); + + console.log(`[Auth] Registered client: ${clientId} (${client.client_name})`); + + res.status(201).json({ + client_id: clientId, + client_secret: clientSecret, + client_name: client.client_name, + redirect_uris: client.redirect_uris, + grant_types: client.grant_types, + response_types: client.response_types, + token_endpoint_auth_method: client.token_endpoint_auth_method + }); +}); + +// --- Authorization endpoint --- +// In a real AS, this would render a login/consent page. +// For this demo, we auto-approve and redirect with an authorization code. + +app.get('/authorize', (req, res) => { + const query = req.query as Record; + const clientId = query.client_id; + const redirectUri = query.redirect_uri; + const responseType = query.response_type; + const codeChallenge = query.code_challenge; + const codeChallengeMethod = query.code_challenge_method; + const scope = query.scope; + const state = query.state; + const resource = query.resource; + + if (responseType !== 'code') { + res.status(400).json({ error: 'unsupported_response_type' }); + return; + } + + if (!clientId || !redirectUri) { + res.status(400).json({ error: 'invalid_request', error_description: 'client_id and redirect_uri are required' }); + return; + } + + const client = clients.get(clientId); + if (!client) { + res.status(400).json({ error: 'invalid_client', error_description: 'Client not registered' }); + return; + } + + if (!client.redirect_uris.includes(redirectUri)) { + res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect_uri' }); + return; + } + + // Generate authorization code + const code = randomBytes(32).toString('base64url'); + const scopes = scope ? scope.split(' ') : ['openid']; + + authorizationCodes.set(code, { + code, + clientId, + redirectUri, + codeChallenge, + codeChallengeMethod, + scopes, + resource, // RFC 8707: Store the requested resource + expiresAt: Date.now() + 10 * 60 * 1000 // 10 minutes + }); + + console.log(`[Auth] Issued authorization code for client ${clientId} (resource: ${resource || 'none'})`); + + // Redirect back with code + const redirectUrl = new URL(redirectUri); + redirectUrl.searchParams.set('code', code); + if (state) { + redirectUrl.searchParams.set('state', state); + } + + res.redirect(redirectUrl.toString()); +}); + +// --- Token endpoint --- + +app.post('/token', async (req, res) => { + const { grant_type: grantType, code, redirect_uri: redirectUri, client_id: clientId, code_verifier: codeVerifier, resource } = req.body; + + if (grantType !== 'authorization_code') { + res.status(400).json({ error: 'unsupported_grant_type' }); + return; + } + + // Look up authorization code + const authCode = authorizationCodes.get(code); + if (!authCode) { + res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid or expired authorization code' }); + return; + } + + // Remove used code (one-time use) + authorizationCodes.delete(code); + + // Validate expiration + if (Date.now() > authCode.expiresAt) { + res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code expired' }); + return; + } + + // Validate client + if (authCode.clientId !== clientId) { + res.status(400).json({ error: 'invalid_grant', error_description: 'Client ID mismatch' }); + return; + } + + // Validate redirect_uri + if (authCode.redirectUri !== redirectUri) { + res.status(400).json({ error: 'invalid_grant', error_description: 'Redirect URI mismatch' }); + return; + } + + // Validate PKCE + if (authCode.codeChallenge) { + if (!codeVerifier) { + res.status(400).json({ error: 'invalid_grant', error_description: 'code_verifier required' }); + return; + } + + // Verify S256 challenge + const encoder = new TextEncoder(); + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(codeVerifier)); + const computed = Buffer.from(digest).toString('base64url'); + + if (computed !== authCode.codeChallenge) { + res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' }); + return; + } + } + + // RFC 8707: Use the resource from the authorization code, or from the token request + const targetResource = authCode.resource || resource || MCP_SERVER_URL; + + // Issue JWT access token (RFC 9068) + const accessToken = await new SignJWT({ + scope: authCode.scopes.join(' '), + client_id: clientId + }) + .setProtectedHeader({ alg: 'RS256', kid: keyId, typ: 'at+jwt' }) + .setIssuer(AUTH_SERVER_URL) + .setSubject(`user-${clientId}`) + .setAudience(targetResource) // RFC 8707: audience is the resource + .setIssuedAt() + .setExpirationTime('1h') + .setJti(randomUUID()) + .sign(privateKey); + + console.log(`[Auth] Issued JWT access token for client ${clientId} (audience: ${targetResource})`); + + res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + scope: authCode.scopes.join(' ') + }); +}); + +// --- Start server --- + +app.listen(AUTH_PORT, () => { + console.log(`External OAuth Authorization Server listening on port ${AUTH_PORT}`); + console.log(` Metadata: ${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`); + console.log(` JWKS: ${AUTH_SERVER_URL}/jwks`); + console.log(` Authorize: ${AUTH_SERVER_URL}/authorize`); + console.log(` Token: ${AUTH_SERVER_URL}/token`); + console.log(` Register: ${AUTH_SERVER_URL}/register`); + console.log(` MCP Resource: ${MCP_SERVER_URL}`); + console.log(); + console.log('NOTE: This server auto-approves all authorization requests (demo only).'); +}); diff --git a/examples/server/src/externalAuthServer/resourceServer.ts b/examples/server/src/externalAuthServer/resourceServer.ts new file mode 100644 index 000000000..a2d1d97d2 --- /dev/null +++ b/examples/server/src/externalAuthServer/resourceServer.ts @@ -0,0 +1,307 @@ +/** + * MCP Resource Server with External Auth Server + * + * DEMO ONLY - NOT FOR PRODUCTION + * + * An MCP server that validates JWT access tokens issued by an external + * OAuth2 authorization server. The server: + * + * 1. Serves OAuth Protected Resource Metadata (RFC 9728) pointing clients + * to the external authorization server + * 2. Validates JWT access tokens using the external AS's JWKS endpoint + * 3. Checks the audience claim matches this resource (RFC 8707) + * + * Run the external auth server (authServer.ts) first, then start this server. + */ + +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { NextFunction, Request, Response } from 'express'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import * as z from 'zod/v4'; + +import { InMemoryEventStore } from '../inMemoryEventStore.js'; + +// --- Configuration --- + +const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; +const AUTH_SERVER_URL = process.env.AUTH_SERVER_URL || 'http://localhost:3001'; +const MCP_SERVER_URL = `http://localhost:${MCP_PORT}/mcp`; + +// --- JWT verification using external AS's JWKS --- + +const JWKS = createRemoteJWKSet(new URL(`${AUTH_SERVER_URL}/jwks`)); + +/** + * Express middleware that validates JWT Bearer tokens from the external AS. + * Checks signature via JWKS, issuer, and audience (RFC 8707). + */ +function requireJwtAuth(expectedAudience: string) { + const resourceMetadataUrl = `http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`; + + return async (req: Request, res: Response, next: NextFunction): Promise => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.set( + 'WWW-Authenticate', + `Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"` + ); + res.status(401).json({ + error: 'invalid_token', + error_description: 'Missing Authorization header' + }); + return; + } + + const token = authHeader.slice(7); + + try { + const { payload } = await jwtVerify(token, JWKS, { + issuer: AUTH_SERVER_URL, + audience: expectedAudience + }); + + // Store verified token info for downstream handlers + req.app.locals.auth = { + sub: payload.sub, + clientId: payload.client_id, + scope: payload.scope, + exp: payload.exp + }; + + console.log(`[MCP] Authenticated request: sub=${payload.sub}, scope=${payload.scope}`); + next(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Token verification failed'; + console.error(`[MCP] JWT verification failed: ${message}`); + + res.set( + 'WWW-Authenticate', + `Bearer error="invalid_token", error_description="${message}", resource_metadata="${resourceMetadataUrl}"` + ); + res.status(401).json({ + error: 'invalid_token', + error_description: message + }); + } + }; +} + +// --- MCP Server setup --- + +const getServer = () => { + const server = new McpServer( + { + name: 'external-auth-example', + version: '1.0.0' + }, + { + capabilities: { logging: {} } + } + ); + + // A simple tool that returns a greeting (demonstrates authenticated access) + server.registerTool( + 'greet', + { + title: 'Greeting Tool', + description: 'A simple greeting tool (requires authentication)', + inputSchema: z.object({ + name: z.string().describe('Name to greet') + }) + }, + async ({ name }): Promise => { + return { + content: [{ type: 'text', text: `Hello, ${name}! (authenticated via external AS)` }] + }; + } + ); + + // A tool that echoes the authenticated user's info + server.registerTool( + 'whoami', + { + title: 'Who Am I', + description: 'Returns information about the authenticated user from the JWT token', + inputSchema: z.object({}) + }, + async (_args, _ctx): Promise => { + // Note: In a real implementation, you would access auth context from the request. + // This demo just confirms authentication succeeded. + return { + content: [ + { + type: 'text', + text: 'You are authenticated via an external OAuth2 authorization server. Your JWT token was verified using the JWKS endpoint.' + } + ] + }; + } + ); + + // A simple resource + server.registerResource( + 'auth-info', + 'https://example.com/auth-info', + { + title: 'Auth Info', + description: 'Information about the authentication setup', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'https://example.com/auth-info', + text: `This MCP server validates JWT tokens from ${AUTH_SERVER_URL}. Tokens are verified using the JWKS endpoint at ${AUTH_SERVER_URL}/jwks.` + } + ] + }; + } + ); + + return server; +}; + +// --- Express app --- + +const app = createMcpExpressApp(); + +app.use( + cors({ + exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'], + origin: '*' + }) +); + +// --- RFC 9728: OAuth Protected Resource Metadata --- +// This tells clients where to find the external authorization server. + +app.get('/.well-known/oauth-protected-resource/mcp', cors(), (_req, res) => { + res.json({ + resource: MCP_SERVER_URL, + authorization_servers: [AUTH_SERVER_URL], + scopes_supported: ['openid', 'profile', 'mcp:tools', 'mcp:resources'], + bearer_methods_supported: ['header'], + resource_name: 'MCP External Auth Example', + resource_documentation: 'https://github.com/modelcontextprotocol/typescript-sdk' + }); +}); + +// --- MCP transport management --- + +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; +const authMiddleware = requireJwtAuth(MCP_SERVER_URL); + +// MCP POST endpoint (authenticated) +app.post('/mcp', authMiddleware, async (req: Request, res: Response) => { + 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)) { + const eventStore = new InMemoryEventStore(); + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, + onsessioninitialized: sid => { + console.log(`[MCP] Session initialized: ${sid}`); + transports[sid] = transport; + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`[MCP] Transport closed for session ${sid}`); + delete transports[sid]; + } + }; + + const server = getServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: No valid session ID provided' }, + id: null + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('[MCP] Error handling request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32_603, message: 'Internal server error' }, + id: null + }); + } + } +}); + +// MCP GET endpoint for SSE streams (authenticated) +app.get('/mcp', authMiddleware, async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// MCP DELETE endpoint for session termination (authenticated) +app.delete('/mcp', authMiddleware, async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// --- Start server --- + +app.listen(MCP_PORT, error => { + if (error) { + console.error('Failed to start MCP server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`MCP Resource Server listening on port ${MCP_PORT}`); + console.log(` MCP endpoint: ${MCP_SERVER_URL}`); + console.log(` Protected Resource Meta: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); + console.log(` External Auth Server: ${AUTH_SERVER_URL}`); + console.log(); + console.log('JWT tokens from the external AS are verified via JWKS.'); +}); + +// --- Graceful shutdown --- + +process.on('SIGINT', async () => { + console.log('Shutting down MCP server...'); + for (const sessionId in transports) { + try { + await transports[sessionId]!.close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d55469be..efb5e8b49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,6 +378,9 @@ importers: hono: specifier: catalog:runtimeServerOnly version: 4.11.4 + jose: + specifier: catalog:runtimeClientOnly + version: 6.1.3 valibot: specifier: catalog:devTools version: 1.2.0(typescript@5.9.3) From ca06ce9e9278314e8db327ed104e64e94c947425 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 23 Mar 2026 19:06:20 +0000 Subject: [PATCH 2/2] fix: point README link to file instead of directory for typedoc --- examples/server/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/server/README.md b/examples/server/README.md index b7852776c..4538984a8 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -38,7 +38,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 auth server (RFC 8707) | MCP server + separate OAuth2 AS with JWT tokens and JWKS verification. | [`src/externalAuthServer/`](src/externalAuthServer/) | +| External auth server (RFC 8707) | MCP server + separate OAuth2 AS with JWT tokens and JWKS verification. | [`src/externalAuthServer/`](src/externalAuthServer/README.md) | ## OAuth demo flags (Streamable HTTP server)