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..4538984a8 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/README.md) | ## 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)