From d7f09b6c3dcfa2cf96acdbb441e98f29124d161e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 11:18:04 +0000 Subject: [PATCH 01/12] wip server auth conformance first cut --- .../servers/typescript/auth-test-server.ts | 306 +++++++++++ package.json | 5 +- src/fake-auth-server.ts | 85 +++ src/index.ts | 235 ++++++-- src/runner/index.ts | 2 + src/runner/server.ts | 217 +++++++- src/scenarios/index.ts | 20 + src/scenarios/server-auth/basic-dcr-flow.ts | 503 ++++++++++++++++++ .../server-auth/helpers/oauth-client.ts | 281 ++++++++++ src/scenarios/server-auth/index.ts | 44 ++ src/scenarios/server-auth/spec-references.ts | 127 +++++ 11 files changed, 1780 insertions(+), 45 deletions(-) create mode 100644 examples/servers/typescript/auth-test-server.ts create mode 100644 src/fake-auth-server.ts create mode 100644 src/scenarios/server-auth/basic-dcr-flow.ts create mode 100644 src/scenarios/server-auth/helpers/oauth-client.ts create mode 100644 src/scenarios/server-auth/index.ts create mode 100644 src/scenarios/server-auth/spec-references.ts diff --git a/examples/servers/typescript/auth-test-server.ts b/examples/servers/typescript/auth-test-server.ts new file mode 100644 index 0000000..deeca51 --- /dev/null +++ b/examples/servers/typescript/auth-test-server.ts @@ -0,0 +1,306 @@ +#!/usr/bin/env node + +/** + * MCP Auth Test Server - Conformance Test Server with Authentication + * + * A minimal MCP server that requires Bearer token authentication. + * This server is used for testing OAuth authentication flows in conformance tests. + * + * Required environment variables: + * - MCP_CONFORMANCE_AUTH_SERVER_URL: URL of the authorization server + * + * Optional environment variables: + * - PORT: Server port (default: 3001) + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; +import express, { Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import { randomUUID } from 'crypto'; + +// Check for required environment variable +const AUTH_SERVER_URL = process.env.MCP_CONFORMANCE_AUTH_SERVER_URL; +if (!AUTH_SERVER_URL) { + console.error( + 'Error: MCP_CONFORMANCE_AUTH_SERVER_URL environment variable is required' + ); + console.error( + 'Usage: MCP_CONFORMANCE_AUTH_SERVER_URL=http://localhost:3000 npx tsx auth-test-server.ts' + ); + process.exit(1); +} + +// Server configuration +const PORT = process.env.PORT || 3001; +const getBaseUrl = () => `http://localhost:${PORT}`; + +// Session management +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const servers: { [sessionId: string]: McpServer } = {}; + +// Function to create a new MCP server instance (one per session) +function createMcpServer(): McpServer { + const mcpServer = new McpServer( + { + name: 'mcp-auth-test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Simple echo tool for testing authenticated calls + mcpServer.tool( + 'echo', + 'Echoes back the provided message - used for testing authenticated calls', + { + message: z.string().optional().describe('The message to echo back') + }, + async (args: { message?: string }) => { + const message = args.message || 'No message provided'; + return { + content: [{ type: 'text', text: `Echo: ${message}` }] + }; + } + ); + + // Simple test tool with no arguments + mcpServer.tool( + 'test-tool', + 'A simple test tool that returns a success message', + {}, + async () => { + return { + content: [{ type: 'text', text: 'test' }] + }; + } + ); + + return mcpServer; +} + +/** + * Validates a Bearer token. + * Accepts tokens that start with 'test-token' or 'cc-token' (as issued by the fake auth server). + */ +function isValidToken(token: string): boolean { + return token.startsWith('test-token') || token.startsWith('cc-token'); +} + +/** + * Bearer authentication middleware. + * Returns 401 with WWW-Authenticate header if token is missing or invalid. + */ +function bearerAuthMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + + // Check for Authorization header + if (!authHeader) { + sendUnauthorized(res, 'Missing authorization header'); + return; + } + + // Check for Bearer scheme + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { + sendUnauthorized(res, 'Invalid authorization scheme'); + return; + } + + const token = parts[1]; + + // Validate the token + if (!isValidToken(token)) { + sendUnauthorized(res, 'Invalid token'); + return; + } + + // Token is valid, proceed + next(); +} + +/** + * Sends a 401 Unauthorized response with proper WWW-Authenticate header. + */ +function sendUnauthorized(res: Response, error: string): void { + const prmUrl = `${getBaseUrl()}/.well-known/oauth-protected-resource`; + + // Build WWW-Authenticate header with resource_metadata parameter + const wwwAuthenticate = `Bearer realm="mcp", error="invalid_token", error_description="${error}", resource_metadata="${prmUrl}"`; + + res.setHeader('WWW-Authenticate', wwwAuthenticate); + res.status(401).json({ + error: 'unauthorized', + error_description: error + }); +} + +// Helper to check if request is an initialize request +function isInitializeRequest(body: any): boolean { + return body?.method === 'initialize'; +} + +// ===== EXPRESS APP ===== + +const app = express(); +app.use(express.json()); + +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id'], + allowedHeaders: [ + 'Content-Type', + 'mcp-session-id', + 'last-event-id', + 'Authorization' + ] + }) +); + +// Protected Resource Metadata endpoint (RFC 9728) +app.get( + '/.well-known/oauth-protected-resource', + (_req: Request, res: Response) => { + res.json({ + resource: getBaseUrl(), + authorization_servers: [AUTH_SERVER_URL] + }); + } +); + +// Handle POST requests to /mcp with bearer auth +app.post('/mcp', bearerAuthMiddleware, async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport for established sessions + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // Create new transport for initialization requests + const mcpServer = createMcpServer(); + + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (newSessionId) => { + transports[newSessionId] = transport; + servers[newSessionId] = mcpServer; + console.log(`Session initialized with ID: ${newSessionId}`); + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + if (servers[sid]) { + servers[sid].close(); + delete servers[sid]; + } + console.log(`Session ${sid} closed`); + } + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Invalid or missing session ID' + }, + 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: -32603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +// Handle GET requests - SSE streams for sessions (also requires auth) +app.get('/mcp', bearerAuthMiddleware, 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; + } + + console.log(`Establishing SSE stream for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } +}); + +// Handle DELETE requests - session termination (also requires auth) +app.delete( + '/mcp', + bearerAuthMiddleware, + 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; + } + + console.log( + `Received session termination request for session ${sessionId}` + ); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } + } +); + +// Start server +app.listen(PORT, () => { + console.log(`MCP Auth Test Server running on http://localhost:${PORT}`); + console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); + console.log( + ` - PRM endpoint: http://localhost:${PORT}/.well-known/oauth-protected-resource` + ); + console.log(` - Auth server: ${AUTH_SERVER_URL}`); +}); diff --git a/package.json b/package.json index dab5ea1..cb755df 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "start": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", - "build": "tsdown src/index.ts --minify --clean --target node20", + "build": "tsdown src/index.ts src/fake-auth-server.ts --minify --clean --target node20", "lint": "eslint src/ examples/ && prettier --check .", "lint:fix": "eslint src/ examples/ --fix && prettier --write .", "lint:fix_check": "npm run lint:fix && git diff --exit-code --quiet", @@ -26,7 +26,8 @@ "dist" ], "bin": { - "conformance": "dist/index.js" + "conformance": "dist/index.js", + "fake-auth-server": "dist/fake-auth-server.js" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/src/fake-auth-server.ts b/src/fake-auth-server.ts new file mode 100644 index 0000000..b62d151 --- /dev/null +++ b/src/fake-auth-server.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import { createAuthServer } from './scenarios/client/auth/helpers/createAuthServer'; +import { ServerLifecycle } from './scenarios/client/auth/helpers/serverLifecycle'; +import type { ConformanceCheck } from './types'; + +const program = new Command(); + +program + .name('fake-auth-server') + .description( + 'Standalone fake OAuth authorization server for testing MCP clients' + ) + .option('--port ', 'Port to listen on (0 for random)', '0') + .action(async (options) => { + const port = parseInt(options.port, 10); + const checks: ConformanceCheck[] = []; + + // If a specific port is requested, we need to handle URL differently + if (port !== 0) { + // For fixed port, we need to track the URL ourselves since we're not using lifecycle.start() + let serverUrl = ''; + const getUrl = () => serverUrl; + + const app = createAuthServer(checks, getUrl, { + loggingEnabled: true + }); + + const httpServer = app.listen(port, () => { + const address = httpServer.address(); + const actualPort = + typeof address === 'object' && address ? address.port : port; + serverUrl = `http://localhost:${actualPort}`; + console.log(`Fake Auth Server running at ${serverUrl}`); + console.log(''); + console.log('Endpoints:'); + console.log( + ` Metadata: ${serverUrl}/.well-known/oauth-authorization-server` + ); + console.log(` Authorization: ${serverUrl}/authorize`); + console.log(` Token: ${serverUrl}/token`); + console.log(` Registration: ${serverUrl}/register`); + console.log(''); + console.log('Press Ctrl+C to stop'); + }); + + // Handle graceful shutdown + process.on('SIGINT', () => { + console.log('\nShutting down...'); + httpServer.close(() => { + process.exit(0); + }); + }); + } else { + // Use ServerLifecycle for random port assignment + const lifecycle = new ServerLifecycle(); + + const app = createAuthServer(checks, lifecycle.getUrl, { + loggingEnabled: true + }); + + const url = await lifecycle.start(app); + console.log(`Fake Auth Server running at ${url}`); + console.log(''); + console.log('Endpoints:'); + console.log( + ` Metadata: ${url}/.well-known/oauth-authorization-server` + ); + console.log(` Authorization: ${url}/authorize`); + console.log(` Token: ${url}/token`); + console.log(` Registration: ${url}/register`); + console.log(''); + console.log('Press Ctrl+C to stop'); + + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('\nShutting down...'); + await lifecycle.stop(); + process.exit(0); + }); + } + }); + +program.parse(); diff --git a/src/index.ts b/src/index.ts index b1e0dba..0101bd3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ import { runConformanceTest, printClientResults, runServerConformanceTest, + runServerAuthConformanceTest, + startFakeAuthServer, printServerResults, printServerSummary, runInteractiveMode @@ -16,7 +18,8 @@ import { listActiveClientScenarios, listPendingClientScenarios, listAuthScenarios, - listMetadataScenarios + listMetadataScenarios, + listServerAuthScenarios } from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; @@ -199,57 +202,63 @@ program program .command('server') .description('Run conformance tests against a server implementation') - .requiredOption('--url ', 'URL of the server to test') + .option('--url ', 'URL of the server to test') + .option( + '--auth-url ', + 'URL for auth testing (when server is already running)' + ) + .option( + '--auth-command ', + 'Command to start the server (conformance will start fake AS and pass MCP_CONFORMANCE_AUTH_SERVER_URL)' + ) .option( '--scenario ', 'Scenario to test (defaults to active suite if not specified)' ) .option( '--suite ', - 'Suite to run: "active" (default, excludes pending), "all", or "pending"', + 'Suite to run: "active" (default, excludes pending), "all", "pending", or "auth"', 'active' ) + .option('--timeout ', 'Timeout in milliseconds', '30000') .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { - // Validate options with Zod - const validated = ServerOptionsSchema.parse(options); - const verbose = options.verbose ?? false; + const timeout = parseInt(options.timeout, 10); + const suite = options.suite?.toLowerCase() || 'active'; + + // Check if this is an auth test + const isAuthTest = + suite === 'auth' || + options.authUrl || + options.authCommand || + options.scenario?.startsWith('server-auth/'); + + if (isAuthTest) { + // Auth testing mode + if (!options.authUrl && !options.authCommand) { + console.error( + 'For auth testing, either --auth-url or --auth-command is required' + ); + console.error( + '\n--auth-url: URL of already running server to test auth against' + ); + console.error( + '--auth-command: Command to start the server (conformance will provide MCP_CONFORMANCE_AUTH_SERVER_URL)' + ); + process.exit(1); + } - // If a single scenario is specified, run just that one - if (validated.scenario) { - const result = await runServerConformanceTest( - validated.url, - validated.scenario - ); - - const { failed } = printServerResults( - result.checks, - result.scenarioDescription, - verbose - ); - process.exit(failed > 0 ? 1 : 0); - } else { - // Run scenarios based on suite - const suite = options.suite?.toLowerCase() || 'active'; + // Get scenarios to run let scenarios: string[]; - - if (suite === 'all') { - scenarios = listClientScenarios(); - } else if (suite === 'active') { - scenarios = listActiveClientScenarios(); - } else if (suite === 'pending') { - scenarios = listPendingClientScenarios(); + if (options.scenario) { + scenarios = [options.scenario]; } else { - console.error(`Unknown suite: ${suite}`); - console.error('Available suites: active, all, pending'); - process.exit(1); + scenarios = listServerAuthScenarios(); } - console.log( - `Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n` - ); + console.log(`Running auth suite (${scenarios.length} scenarios)...\n`); const allResults: { scenario: string; checks: ConformanceCheck[] }[] = []; @@ -257,11 +266,21 @@ program for (const scenarioName of scenarios) { console.log(`\n=== Running scenario: ${scenarioName} ===`); try { - const result = await runServerConformanceTest( - validated.url, - scenarioName - ); + const result = await runServerAuthConformanceTest({ + authUrl: options.authUrl, + authCommand: options.authCommand, + scenarioName, + timeout + }); allResults.push({ scenario: scenarioName, checks: result.checks }); + + if (verbose) { + printServerResults( + result.checks, + result.scenarioDescription, + verbose + ); + } } catch (error) { console.error(`Failed to run scenario ${scenarioName}:`, error); allResults.push({ @@ -283,6 +302,85 @@ program const { totalFailed } = printServerSummary(allResults); process.exit(totalFailed > 0 ? 1 : 0); + } else { + // Standard server testing mode - requires --url + if (!options.url) { + console.error('--url is required for non-auth server testing'); + process.exit(1); + } + + // Validate options with Zod + const validated = ServerOptionsSchema.parse(options); + + // If a single scenario is specified, run just that one + if (validated.scenario) { + const result = await runServerConformanceTest( + validated.url, + validated.scenario + ); + + const { failed } = printServerResults( + result.checks, + result.scenarioDescription, + verbose + ); + process.exit(failed > 0 ? 1 : 0); + } else { + // Run scenarios based on suite + let scenarios: string[]; + + if (suite === 'all') { + scenarios = listClientScenarios(); + } else if (suite === 'active') { + scenarios = listActiveClientScenarios(); + } else if (suite === 'pending') { + scenarios = listPendingClientScenarios(); + } else { + console.error(`Unknown suite: ${suite}`); + console.error('Available suites: active, all, pending, auth'); + process.exit(1); + } + + console.log( + `Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n` + ); + + const allResults: { scenario: string; checks: ConformanceCheck[] }[] = + []; + + for (const scenarioName of scenarios) { + console.log(`\n=== Running scenario: ${scenarioName} ===`); + try { + const result = await runServerConformanceTest( + validated.url, + scenarioName + ); + allResults.push({ + scenario: scenarioName, + checks: result.checks + }); + } catch (error) { + console.error(`Failed to run scenario ${scenarioName}:`, error); + allResults.push({ + scenario: scenarioName, + checks: [ + { + id: scenarioName, + name: scenarioName, + description: 'Failed to run scenario', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + error instanceof Error ? error.message : String(error) + } + ] + }); + } + } + + const { totalFailed } = printServerSummary(allResults); + process.exit(totalFailed > 0 ? 1 : 0); + } } } catch (error) { if (error instanceof ZodError) { @@ -292,6 +390,8 @@ program }); console.error('\nAvailable server scenarios:'); listClientScenarios().forEach((s) => console.error(` - ${s}`)); + console.error('\nAvailable server auth scenarios:'); + listServerAuthScenarios().forEach((s) => console.error(` - ${s}`)); process.exit(1); } console.error('Server test error:', error); @@ -305,15 +405,27 @@ program .description('List available test scenarios') .option('--client', 'List client scenarios') .option('--server', 'List server scenarios') + .option('--server-auth', 'List server auth scenarios') .action((options) => { - if (options.server || (!options.client && !options.server)) { + const showAll = !options.client && !options.server && !options.serverAuth; + + if (options.server || showAll) { console.log('Server scenarios (test against a server):'); const serverScenarios = listClientScenarios(); serverScenarios.forEach((s) => console.log(` - ${s}`)); } - if (options.client || (!options.client && !options.server)) { - if (options.server || (!options.client && !options.server)) { + if (options.serverAuth || showAll) { + if (options.server || showAll) { + console.log(''); + } + console.log('Server auth scenarios (test server auth implementation):'); + const authScenarios = listServerAuthScenarios(); + authScenarios.forEach((s) => console.log(` - ${s}`)); + } + + if (options.client || showAll) { + if (options.server || options.serverAuth || showAll) { console.log(''); } console.log('Client scenarios (test against a client):'); @@ -322,4 +434,43 @@ program } }); +// Fake auth server command - starts a standalone fake authorization server +program + .command('fake-auth-server') + .description( + 'Start a standalone fake authorization server for manual testing' + ) + .option('--port ', 'Port to listen on (default: random)') + .action(async (options) => { + const port = options.port ? parseInt(options.port, 10) : undefined; + + console.log('Starting fake authorization server...'); + const { url, stop } = await startFakeAuthServer(port); + console.log(`\nFake authorization server running at: ${url}`); + console.log('\nEndpoints:'); + console.log( + ` Metadata: ${url}/.well-known/oauth-authorization-server` + ); + console.log(` Authorization: ${url}/authorize`); + console.log(` Token: ${url}/token`); + console.log(` Registration: ${url}/register`); + console.log('\nPress Ctrl+C to stop.'); + + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('\nShutting down...'); + await stop(); + process.exit(0); + }); + + process.on('SIGTERM', async () => { + console.log('\nShutting down...'); + await stop(); + process.exit(0); + }); + + // Keep the process running + await new Promise(() => {}); + }); + program.parse(); diff --git a/src/runner/index.ts b/src/runner/index.ts index d48a291..7f642cb 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -9,6 +9,8 @@ export { // Export server functions export { runServerConformanceTest, + runServerAuthConformanceTest, + startFakeAuthServer, printServerResults, printServerSummary } from './server'; diff --git a/src/runner/server.ts b/src/runner/server.ts index 18c8254..2365e88 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -1,8 +1,11 @@ import { promises as fs } from 'fs'; import path from 'path'; +import { spawn, ChildProcess } from 'child_process'; import { ConformanceCheck } from '../types'; -import { getClientScenario } from '../scenarios'; +import { getClientScenario, getServerAuthScenario } from '../scenarios'; import { ensureResultsDir, createResultDir, formatPrettyChecks } from './utils'; +import { createAuthServer } from '../scenarios/client/auth/helpers/createAuthServer'; +import { ServerLifecycle } from '../scenarios/client/auth/helpers/serverLifecycle'; /** * Format markdown-style text for terminal output using ANSI codes @@ -119,3 +122,215 @@ export function printServerSummary( return { totalPassed, totalFailed }; } + +/** + * Wait for a URL to become available by polling + */ +async function waitForServerReady( + url: string, + timeoutMs: number = 30000, + intervalMs: number = 500 +): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + try { + const response = await fetch(url, { method: 'HEAD' }); + if (response.ok || response.status === 401 || response.status === 404) { + // Server is up (401/404 are acceptable - means server is responding) + return; + } + } catch { + // Server not ready yet, keep polling + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error( + `Server at ${url} did not become ready within ${timeoutMs}ms` + ); +} + +/** + * Run server auth conformance test + * + * For --auth-command mode: Spawns the fake AS, then spawns the server with + * MCP_CONFORMANCE_AUTH_SERVER_URL env var pointing to the fake AS. + * + * For --auth-url mode: Just runs the auth scenario against the provided URL. + */ +export async function runServerAuthConformanceTest(options: { + authUrl?: string; + authCommand?: string; + scenarioName: string; + timeout?: number; +}): Promise<{ + checks: ConformanceCheck[]; + resultDir: string; + scenarioDescription: string; +}> { + const { authUrl, authCommand, scenarioName, timeout = 30000 } = options; + + await ensureResultsDir(); + const resultDir = createResultDir(scenarioName, 'server-auth'); + await fs.mkdir(resultDir, { recursive: true }); + + // Get the scenario + const scenario = getServerAuthScenario(scenarioName); + if (!scenario) { + throw new Error(`Unknown server auth scenario: ${scenarioName}`); + } + + let checks: ConformanceCheck[] = []; + let serverProcess: ChildProcess | null = null; + let authServerLifecycle: ServerLifecycle | null = null; + + try { + if (authCommand) { + // --auth-command mode: Start fake AS, then spawn server with env var + console.log(`Starting fake authorization server...`); + + authServerLifecycle = new ServerLifecycle(); + const authApp = createAuthServer(checks, authServerLifecycle.getUrl); + const authServerUrl = await authServerLifecycle.start(authApp); + console.log(`Fake AS running at ${authServerUrl}`); + + // Spawn the server command with the auth server URL env var + console.log(`Starting server with command: ${authCommand}`); + serverProcess = spawn(authCommand, { + shell: true, + env: { + ...process.env, + MCP_CONFORMANCE_AUTH_SERVER_URL: authServerUrl + }, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + // Collect server output for debugging + let serverOutput = ''; + serverProcess.stdout?.on('data', (data) => { + serverOutput += data.toString(); + }); + serverProcess.stderr?.on('data', (data) => { + serverOutput += data.toString(); + }); + + // Wait for server to be ready + // The server should output its URL or we need a way to determine it + // For now, we'll assume the server outputs its URL to stdout + const serverUrl = await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject( + new Error( + `Server did not start within ${timeout}ms. Output: ${serverOutput}` + ) + ); + }, timeout); + + // Look for URL in server output + const checkOutput = () => { + const urlMatch = serverOutput.match(/https?:\/\/localhost:\d+/); + if (urlMatch) { + clearTimeout(timeoutId); + resolve(urlMatch[0]); + } + }; + + serverProcess!.stdout?.on('data', checkOutput); + serverProcess!.stderr?.on('data', checkOutput); + + serverProcess!.on('error', (err) => { + clearTimeout(timeoutId); + reject(err); + }); + + serverProcess!.on('exit', (code) => { + if (code !== null && code !== 0) { + clearTimeout(timeoutId); + reject( + new Error( + `Server process exited with code ${code}. Output: ${serverOutput}` + ) + ); + } + }); + }); + + console.log(`Server running at ${serverUrl}`); + await waitForServerReady(serverUrl); + + // Run the scenario + console.log( + `Running server auth scenario '${scenarioName}' against server: ${serverUrl}` + ); + const scenarioChecks = await scenario.run(serverUrl); + checks.push(...scenarioChecks); + } else if (authUrl) { + // --auth-url mode: Just run the scenario against the provided URL + console.log( + `Running server auth scenario '${scenarioName}' against: ${authUrl}` + ); + checks = await scenario.run(authUrl); + } else { + throw new Error( + 'Either --auth-url or --auth-command must be provided for auth scenarios' + ); + } + + await fs.writeFile( + path.join(resultDir, 'checks.json'), + JSON.stringify(checks, null, 2) + ); + + console.log(`Results saved to ${resultDir}`); + + return { + checks, + resultDir, + scenarioDescription: scenario.description + }; + } finally { + // Cleanup + if (serverProcess) { + console.log('Stopping server process...'); + serverProcess.kill(); + } + if (authServerLifecycle) { + console.log('Stopping fake authorization server...'); + await authServerLifecycle.stop(); + } + } +} + +/** + * Start a standalone fake authorization server for manual testing + */ +export async function startFakeAuthServer(port?: number): Promise<{ + url: string; + stop: () => Promise; +}> { + const checks: ConformanceCheck[] = []; + const lifecycle = new ServerLifecycle(); + const app = createAuthServer(checks, lifecycle.getUrl, { + loggingEnabled: true + }); + + if (port) { + // If a specific port is requested, we need to handle it differently + const httpServer = app.listen(port); + const url = `http://localhost:${port}`; + return { + url, + stop: async () => { + await new Promise((resolve) => { + httpServer.closeAllConnections?.(); + httpServer.close(() => resolve()); + }); + } + }; + } + + const url = await lifecycle.start(app); + return { + url, + stop: () => lifecycle.stop() + }; +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 470ffed..d4ac2a3 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -53,6 +53,10 @@ import { import { authScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; +import { + serverAuthScenarios as serverAuthScenariosList, + getServerAuthScenario as getServerAuthScenarioFromModule +} from './server-auth'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ @@ -187,3 +191,19 @@ export function listAuthScenarios(): string[] { } export { listMetadataScenarios }; + +// Server auth scenario helpers +// Map for server auth scenarios (for consistency with other scenario maps) +export const serverAuthScenarios = new Map( + serverAuthScenariosList.map((scenario) => [scenario.name, scenario]) +); + +export function getServerAuthScenario( + name: string +): ClientScenario | undefined { + return getServerAuthScenarioFromModule(name); +} + +export function listServerAuthScenarios(): string[] { + return serverAuthScenariosList.map((s) => s.name); +} diff --git a/src/scenarios/server-auth/basic-dcr-flow.ts b/src/scenarios/server-auth/basic-dcr-flow.ts new file mode 100644 index 0000000..c78a6b0 --- /dev/null +++ b/src/scenarios/server-auth/basic-dcr-flow.ts @@ -0,0 +1,503 @@ +/** + * Basic DCR Flow Scenario + * + * Tests the complete OAuth authentication flow using Dynamic Client Registration: + * 1. Unauthenticated MCP request triggers 401 + WWW-Authenticate header + * 2. Protected Resource Metadata (PRM) discovery + * 3. Authorization Server (AS) metadata discovery + * 4. Dynamic Client Registration (DCR) + * 5. Token acquisition via authorization_code flow + * 6. Authenticated MCP tool call with Bearer token + * + * This scenario uses the MCP SDK's real client with observation middleware + * to verify server conformance. + */ + +import type { ClientScenario, ConformanceCheck } from '../../types'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { applyMiddlewares } from '@modelcontextprotocol/sdk/client/middleware.js'; +import { + auth, + extractWWWAuthenticateParams, + UnauthorizedError +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { + ConformanceOAuthProvider, + createObservationMiddleware, + type ObservedRequest +} from './helpers/oauth-client'; +import { ServerAuthSpecReferences } from './spec-references'; + +/** + * Basic DCR Flow - Tests complete OAuth flow with Dynamic Client Registration. + */ +export class BasicDcrFlowScenario implements ClientScenario { + name = 'server-auth/basic-dcr-flow'; + description = `Tests the complete OAuth authentication flow using Dynamic Client Registration. + +**Flow tested:** +1. Unauthenticated MCP request -> 401 + WWW-Authenticate +2. PRM Discovery -> authorization_servers +3. AS Metadata Discovery -> registration_endpoint, token_endpoint +4. DCR Registration -> client_id, client_secret +5. Token Acquisition -> access_token +6. Authenticated MCP Call -> success + +**Spec References:** +- RFC 9728 (Protected Resource Metadata) +- RFC 8414 (Authorization Server Metadata) +- RFC 7591 (Dynamic Client Registration) +- RFC 6750 (Bearer Token Usage) +- MCP Authorization Specification`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const observedRequests: ObservedRequest[] = []; + const timestamp = () => new Date().toISOString(); + + // Create observation middleware to record all requests + const observationMiddleware = createObservationMiddleware((req) => { + observedRequests.push(req); + }); + + // Create OAuth provider for conformance testing + const provider = new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: 'MCP Conformance Test Client', + redirect_uris: ['http://localhost:3000/callback'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post' + } + ); + + // Handle 401 with OAuth flow + const handle401 = async ( + response: Response, + next: FetchLike, + url: string + ): Promise => { + const { resourceMetadataUrl, scope } = + extractWWWAuthenticateParams(response); + + let result = await auth(provider, { + serverUrl: url, + resourceMetadataUrl, + scope, + fetchFn: next + }); + + if (result === 'REDIRECT') { + // Get auth code from the redirect (auto-login) + const authorizationCode = await provider.getAuthCode(); + + result = await auth(provider, { + serverUrl: url, + resourceMetadataUrl, + scope, + authorizationCode, + fetchFn: next + }); + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError( + `Authentication failed with result: ${result}` + ); + } + } + }; + + // Create middleware that handles OAuth with observation + const oauthMiddleware = (next: FetchLike): FetchLike => { + return async (input, init) => { + const headers = new Headers(init?.headers); + const tokens = await provider.tokens(); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + + const response = await next(input, { ...init, headers }); + + if (response.status === 401) { + const url = typeof input === 'string' ? input : input.toString(); + await handle401(response.clone(), next, url); + // Retry with fresh tokens + const newTokens = await provider.tokens(); + if (newTokens) { + headers.set('Authorization', `Bearer ${newTokens.access_token}`); + } + return await next(input, { ...init, headers }); + } + + return response; + }; + }; + + // Compose middlewares: observation wraps oauth handling + const enhancedFetch = applyMiddlewares( + observationMiddleware, + oauthMiddleware + )(fetch); + + try { + // Create MCP client + const client = new Client( + { name: 'conformance-test-client', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: enhancedFetch + }); + + // Connect triggers the OAuth flow + await client.connect(transport); + + // Make an authenticated call + try { + await client.listTools(); + } catch { + // Tool listing may fail if server doesn't have tools, but that's ok + } + + await transport.close(); + + // Analyze observed requests to generate conformance checks + this.analyzeRequests(observedRequests, checks, timestamp); + } catch (error) { + // Still analyze what we observed before the error + this.analyzeRequests(observedRequests, checks, timestamp); + + checks.push({ + id: 'auth-flow-completion', + name: 'OAuth Flow Completion', + description: 'Complete OAuth authentication flow', + status: 'FAILURE', + timestamp: timestamp(), + errorMessage: error instanceof Error ? error.message : String(error), + specReferences: [ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN] + }); + } + + return checks; + } + + /** + * Analyze observed requests and generate conformance checks. + */ + private analyzeRequests( + requests: ObservedRequest[], + checks: ConformanceCheck[], + timestamp: () => string + ): void { + // Phase 1: Check for 401 response with WWW-Authenticate + const unauthorizedRequest = requests.find( + (r) => r.responseStatus === 401 && r.requestType === 'mcp-request' + ); + + if (unauthorizedRequest) { + checks.push({ + id: 'auth-401-response', + name: 'Unauthenticated Request Returns 401', + description: + 'Server returns 401 Unauthorized for unauthenticated MCP requests', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7235_401_RESPONSE, + ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN + ], + details: { + url: unauthorizedRequest.url, + status: unauthorizedRequest.responseStatus + } + }); + + // Check WWW-Authenticate header + if (unauthorizedRequest.wwwAuthenticate) { + const wwwAuth = unauthorizedRequest.wwwAuthenticate; + + checks.push({ + id: 'auth-www-authenticate-header', + name: 'WWW-Authenticate Header Present', + description: + 'Server includes WWW-Authenticate header in 401 response', + status: + wwwAuth.scheme.toLowerCase() === 'bearer' ? 'SUCCESS' : 'WARNING', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE, + ServerAuthSpecReferences.RFC_7235_WWW_AUTHENTICATE + ], + details: { + scheme: wwwAuth.scheme, + params: wwwAuth.params + } + }); + + // Check for resource_metadata parameter + if (wwwAuth.params.resource_metadata) { + checks.push({ + id: 'auth-resource-metadata-param', + name: 'Resource Metadata URL in WWW-Authenticate', + description: + 'WWW-Authenticate header includes resource_metadata parameter', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_9728_WWW_AUTHENTICATE + ], + details: { + resourceMetadata: wwwAuth.params.resource_metadata + } + }); + } + } else { + checks.push({ + id: 'auth-www-authenticate-header', + name: 'WWW-Authenticate Header Present', + description: + 'Server should include WWW-Authenticate header in 401 response', + status: 'INFO', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_6750_WWW_AUTHENTICATE] + }); + } + } else { + checks.push({ + id: 'auth-401-response', + name: 'Unauthenticated Request Returns 401', + description: + 'No 401 response observed - server may not require authentication', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_7235_401_RESPONSE] + }); + } + + // Phase 2: PRM Discovery + const prmRequest = requests.find((r) => r.requestType === 'prm-discovery'); + if (prmRequest) { + checks.push({ + id: 'auth-prm-discovery', + name: 'Protected Resource Metadata Discovery', + description: 'Client discovered Protected Resource Metadata endpoint', + status: prmRequest.responseStatus === 200 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_PRM_DISCOVERY + ], + details: { + url: prmRequest.url, + status: prmRequest.responseStatus, + body: prmRequest.responseBody + } + }); + + // Check PRM response content + if ( + prmRequest.responseStatus === 200 && + typeof prmRequest.responseBody === 'object' + ) { + const prm = prmRequest.responseBody as Record; + + if ( + prm.authorization_servers && + Array.isArray(prm.authorization_servers) + ) { + checks.push({ + id: 'auth-prm-authorization-servers', + name: 'PRM Contains Authorization Servers', + description: + 'Protected Resource Metadata includes authorization_servers array', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE], + details: { + authorizationServers: prm.authorization_servers + } + }); + } + } + } + + // Phase 3: AS Metadata Discovery + const asMetadataRequest = requests.find( + (r) => r.requestType === 'as-metadata' + ); + if (asMetadataRequest) { + checks.push({ + id: 'auth-as-metadata-discovery', + name: 'Authorization Server Metadata Discovery', + description: 'Client discovered Authorization Server metadata', + status: + asMetadataRequest.responseStatus === 200 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_SERVER_METADATA + ], + details: { + url: asMetadataRequest.url, + status: asMetadataRequest.responseStatus + } + }); + + // Check AS metadata required fields + if ( + asMetadataRequest.responseStatus === 200 && + typeof asMetadataRequest.responseBody === 'object' + ) { + const metadata = asMetadataRequest.responseBody as Record< + string, + unknown + >; + const hasTokenEndpoint = !!metadata.token_endpoint; + const hasRegistrationEndpoint = !!metadata.registration_endpoint; + + checks.push({ + id: 'auth-as-metadata-fields', + name: 'AS Metadata Required Fields', + description: + 'Authorization Server metadata includes required endpoints', + status: hasTokenEndpoint ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + details: { + hasTokenEndpoint, + hasRegistrationEndpoint, + tokenEndpoint: metadata.token_endpoint, + registrationEndpoint: metadata.registration_endpoint + } + }); + } + } + + // Phase 4: DCR Registration + const dcrRequest = requests.find( + (r) => r.requestType === 'dcr-registration' + ); + if (dcrRequest) { + checks.push({ + id: 'auth-dcr-registration', + name: 'Dynamic Client Registration', + description: 'Client registered via Dynamic Client Registration', + status: dcrRequest.responseStatus === 201 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_7591_DCR_ENDPOINT, + ServerAuthSpecReferences.MCP_AUTH_DCR + ], + details: { + url: dcrRequest.url, + status: dcrRequest.responseStatus + } + }); + + // Check DCR response + if ( + dcrRequest.responseStatus === 201 && + typeof dcrRequest.responseBody === 'object' + ) { + const client = dcrRequest.responseBody as Record; + + checks.push({ + id: 'auth-dcr-response', + name: 'DCR Response Contains Client Credentials', + description: 'DCR response includes client_id', + status: client.client_id ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_7591_DCR_RESPONSE], + details: { + hasClientId: !!client.client_id, + hasClientSecret: !!client.client_secret + } + }); + } + } + + // Phase 5: Token Request + const tokenRequest = requests.find( + (r) => r.requestType === 'token-request' + ); + if (tokenRequest) { + checks.push({ + id: 'auth-token-request', + name: 'Token Acquisition', + description: 'Client obtained access token from token endpoint', + status: tokenRequest.responseStatus === 200 ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_TOKEN_REQUEST, + ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN + ], + details: { + url: tokenRequest.url, + status: tokenRequest.responseStatus + } + }); + + // Check token response + if ( + tokenRequest.responseStatus === 200 && + typeof tokenRequest.responseBody === 'object' + ) { + const tokens = tokenRequest.responseBody as Record; + + checks.push({ + id: 'auth-token-response', + name: 'Token Response Contains Access Token', + description: 'Token response includes access_token', + status: tokens.access_token ? 'SUCCESS' : 'FAILURE', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.OAUTH_2_1_TOKEN_REQUEST], + details: { + hasAccessToken: !!tokens.access_token, + hasRefreshToken: !!tokens.refresh_token, + tokenType: tokens.token_type + } + }); + } + } + + // Phase 6: Authenticated MCP Request + const authenticatedRequest = requests.find( + (r) => + r.requestType === 'mcp-request' && + r.requestHeaders['authorization']?.startsWith('Bearer ') && + r.responseStatus === 200 + ); + + if (authenticatedRequest) { + checks.push({ + id: 'auth-authenticated-request', + name: 'Authenticated MCP Request Succeeds', + description: 'MCP request with Bearer token succeeds', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_6750_BEARER_TOKEN, + ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN + ], + details: { + url: authenticatedRequest.url, + status: authenticatedRequest.responseStatus + } + }); + + // Overall flow success + checks.push({ + id: 'auth-flow-completion', + name: 'OAuth Flow Completion', + description: 'Complete OAuth authentication flow succeeded', + status: 'SUCCESS', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN] + }); + } + } +} diff --git a/src/scenarios/server-auth/helpers/oauth-client.ts b/src/scenarios/server-auth/helpers/oauth-client.ts new file mode 100644 index 0000000..d9777ed --- /dev/null +++ b/src/scenarios/server-auth/helpers/oauth-client.ts @@ -0,0 +1,281 @@ +/** + * OAuth client provider and observation middleware for conformance testing. + * + * This module provides: + * 1. A conformance-aware OAuthClientProvider that handles auto-login for testing + * 2. An observation middleware that records all HTTP requests for conformance checks + */ + +import type { + OAuthClientMetadata, + OAuthClientInformationFull, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js'; +import { createMiddleware } from '@modelcontextprotocol/sdk/client/middleware.js'; + +/** + * Observed HTTP request/response for conformance checking. + */ +export interface ObservedRequest { + timestamp: string; + method: string; + url: string; + requestHeaders: Record; + responseStatus: number; + responseHeaders: Record; + responseBody?: unknown; + /** Parsed WWW-Authenticate header if present */ + wwwAuthenticate?: { + scheme: string; + params: Record; + }; + /** Classification of the request type */ + requestType?: + | 'mcp-request' + | 'prm-discovery' + | 'as-metadata' + | 'dcr-registration' + | 'token-request' + | 'authorization' + | 'unknown'; +} + +/** + * Observer callback for recording requests. + */ +export type RequestObserver = (request: ObservedRequest) => void; + +/** + * Parse WWW-Authenticate header value. + */ +function parseWWWAuthenticate(headerValue: string): { + scheme: string; + params: Record; +} { + const params: Record = {}; + const spaceIndex = headerValue.indexOf(' '); + + if (spaceIndex === -1) { + return { scheme: headerValue.trim(), params }; + } + + const scheme = headerValue.substring(0, spaceIndex).trim(); + let rest = headerValue.substring(spaceIndex + 1).trim(); + + while (rest.length > 0) { + rest = rest.replace(/^[\s,]+/, ''); + if (rest.length === 0) break; + + const eqMatch = rest.match(/^([^=\s]+)\s*=/); + if (!eqMatch) break; + + const key = eqMatch[1].toLowerCase(); + rest = rest.substring(eqMatch[0].length).trim(); + + let value: string; + if (rest.startsWith('"')) { + let endQuote = 1; + while (endQuote < rest.length) { + if (rest[endQuote] === '"' && rest[endQuote - 1] !== '\\') break; + endQuote++; + } + value = rest.substring(1, endQuote).replace(/\\"/g, '"'); + rest = rest.substring(endQuote + 1); + } else { + const tokenMatch = rest.match(/^([^,\s]+)/); + value = tokenMatch ? tokenMatch[1] : ''; + rest = rest.substring(value.length); + } + params[key] = value; + } + + return { scheme, params }; +} + +/** + * Classify request type based on URL patterns. + */ +function classifyRequest( + url: string, + method: string +): ObservedRequest['requestType'] { + if (url.includes('/.well-known/oauth-protected-resource')) { + return 'prm-discovery'; + } + if ( + url.includes('/.well-known/oauth-authorization-server') || + url.includes('/.well-known/openid-configuration') + ) { + return 'as-metadata'; + } + if (url.includes('/register') && method === 'POST') { + return 'dcr-registration'; + } + if (url.includes('/token') && method === 'POST') { + return 'token-request'; + } + if (url.includes('/authorize')) { + return 'authorization'; + } + if (url.includes('/mcp') && method === 'POST') { + return 'mcp-request'; + } + return 'unknown'; +} + +/** + * Creates an observation middleware that records HTTP requests. + * + * @param observer - Callback function to receive observed requests + * @returns Middleware function + */ +export function createObservationMiddleware( + observer: RequestObserver +): Middleware { + return createMiddleware(async (next, input, init) => { + const url = typeof input === 'string' ? input : input.toString(); + const method = init?.method || 'GET'; + const requestHeaders: Record = {}; + + if (init?.headers) { + const headers = new Headers(init.headers); + headers.forEach((value, key) => { + requestHeaders[key] = value; + }); + } + + const response = await next(input, init); + + // Clone response to read body without consuming it + const clonedResponse = response.clone(); + let responseBody: unknown; + try { + const text = await clonedResponse.text(); + try { + responseBody = JSON.parse(text); + } catch { + responseBody = text; + } + } catch { + // Body not readable + } + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const observed: ObservedRequest = { + timestamp: new Date().toISOString(), + method, + url, + requestHeaders, + responseStatus: response.status, + responseHeaders, + responseBody, + requestType: classifyRequest(url, method) + }; + + // Parse WWW-Authenticate if present + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + if (wwwAuthHeader) { + observed.wwwAuthenticate = parseWWWAuthenticate(wwwAuthHeader); + } + + observer(observed); + return response; + }); +} + +/** + * Conformance OAuth client provider for testing. + * + * This provider: + * - Stores client information and tokens in memory + * - Handles auto-login by fetching the authorization URL and extracting the code from redirect + */ +export class ConformanceOAuthProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _authCode?: string; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata + ) {} + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformationFull | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + /** + * Handle authorization redirect by fetching the URL and extracting auth code. + * This works with auto-login servers that redirect immediately with the code. + */ + async redirectToAuthorization(authorizationUrl: URL): Promise { + try { + const response = await fetch(authorizationUrl.toString(), { + redirect: 'manual' // Don't follow redirects automatically + }); + + // Get the Location header which contains the redirect with auth code + const location = response.headers.get('location'); + if (location) { + const redirectUrl = new URL(location); + const code = redirectUrl.searchParams.get('code'); + if (code) { + this._authCode = code; + return; + } + throw new Error('No auth code in redirect URL'); + } + throw new Error( + `No redirect location received from ${authorizationUrl.toString()}` + ); + } catch (error) { + console.error('Failed to fetch authorization URL:', error); + throw error; + } + } + + async getAuthCode(): Promise { + if (this._authCode) { + return this._authCode; + } + throw new Error('No authorization code'); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} diff --git a/src/scenarios/server-auth/index.ts b/src/scenarios/server-auth/index.ts new file mode 100644 index 0000000..6e3fcb9 --- /dev/null +++ b/src/scenarios/server-auth/index.ts @@ -0,0 +1,44 @@ +/** + * Server Authentication Conformance Scenarios + * + * This module exports scenarios for testing MCP servers' OAuth implementation. + * These are client scenarios that connect to a server and verify its conformance + * with OAuth-related RFCs and the MCP authorization specification. + */ + +import type { ClientScenario } from '../../types'; +import { BasicDcrFlowScenario } from './basic-dcr-flow'; + +// Re-export helpers and spec references +export * from './helpers/oauth-client'; +export * from './spec-references'; +export { BasicDcrFlowScenario } from './basic-dcr-flow'; + +/** + * All server authentication scenarios. + */ +export const serverAuthScenarios: ClientScenario[] = [ + new BasicDcrFlowScenario() +]; + +/** + * List all available server auth scenarios. + */ +export function listServerAuthScenarios(): { + name: string; + description: string; +}[] { + return serverAuthScenarios.map((s) => ({ + name: s.name, + description: s.description + })); +} + +/** + * Get a server auth scenario by name. + */ +export function getServerAuthScenario( + name: string +): ClientScenario | undefined { + return serverAuthScenarios.find((s) => s.name === name); +} diff --git a/src/scenarios/server-auth/spec-references.ts b/src/scenarios/server-auth/spec-references.ts new file mode 100644 index 0000000..ad99169 --- /dev/null +++ b/src/scenarios/server-auth/spec-references.ts @@ -0,0 +1,127 @@ +/** + * Specification references for server OAuth conformance tests. + * + * Links test checks to relevant specifications: + * - RFC 9728 (Protected Resource Metadata) + * - RFC 8414 (Authorization Server Metadata) + * - RFC 7591 (Dynamic Client Registration) + * - RFC 6750 (Bearer Token Usage) + * - OAuth 2.1 Draft (Client Credentials, Token Endpoint Auth) + * - MCP Authorization Specification (2025-06-18) + */ + +import { SpecReference } from '../../types'; + +export const ServerAuthSpecReferences: { [key: string]: SpecReference } = { + // ───────────────────────────────────────────────────────────────────────── + // RFC 9728: Protected Resource Metadata + // ───────────────────────────────────────────────────────────────────────── + RFC_9728_PRM_DISCOVERY: { + id: 'RFC-9728-discovery', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3' + }, + RFC_9728_PRM_RESPONSE: { + id: 'RFC-9728-response', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2' + }, + RFC_9728_WWW_AUTHENTICATE: { + id: 'RFC-9728-www-authenticate', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-5' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 8414: Authorization Server Metadata + // ───────────────────────────────────────────────────────────────────────── + RFC_8414_AS_DISCOVERY: { + id: 'RFC-8414-discovery', + url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-3' + }, + RFC_8414_AS_FIELDS: { + id: 'RFC-8414-fields', + url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-2' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 7591: Dynamic Client Registration (DCR) + // ───────────────────────────────────────────────────────────────────────── + RFC_7591_DCR_ENDPOINT: { + id: 'RFC-7591-endpoint', + url: 'https://www.rfc-editor.org/rfc/rfc7591.html#section-3' + }, + RFC_7591_DCR_REQUEST: { + id: 'RFC-7591-request', + url: 'https://www.rfc-editor.org/rfc/rfc7591.html#section-3.1' + }, + RFC_7591_DCR_RESPONSE: { + id: 'RFC-7591-response', + url: 'https://www.rfc-editor.org/rfc/rfc7591.html#section-3.2' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 6750: Bearer Token Usage + // ───────────────────────────────────────────────────────────────────────── + RFC_6750_BEARER_TOKEN: { + id: 'RFC-6750-bearer', + url: 'https://www.rfc-editor.org/rfc/rfc6750.html#section-2.1' + }, + RFC_6750_WWW_AUTHENTICATE: { + id: 'RFC-6750-www-authenticate', + url: 'https://www.rfc-editor.org/rfc/rfc6750.html#section-3' + }, + + // ───────────────────────────────────────────────────────────────────────── + // RFC 7235: HTTP Authentication + // ───────────────────────────────────────────────────────────────────────── + RFC_7235_401_RESPONSE: { + id: 'RFC-7235-401', + url: 'https://www.rfc-editor.org/rfc/rfc7235.html#section-3.1' + }, + RFC_7235_WWW_AUTHENTICATE: { + id: 'RFC-7235-www-authenticate', + url: 'https://www.rfc-editor.org/rfc/rfc7235.html#section-4.1' + }, + + // ───────────────────────────────────────────────────────────────────────── + // OAuth 2.1 Draft + // ───────────────────────────────────────────────────────────────────────── + OAUTH_2_1_CLIENT_CREDENTIALS: { + id: 'OAuth-2.1-client-credentials', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.2' + }, + OAUTH_2_1_TOKEN_REQUEST: { + id: 'OAuth-2.1-token-request', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-token-request' + }, + + // ───────────────────────────────────────────────────────────────────────── + // MCP Authorization Specification (2025-06-18) + // ───────────────────────────────────────────────────────────────────────── + MCP_AUTH_SERVER_LOCATION: { + id: 'MCP-2025-06-18-server-location', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location' + }, + MCP_AUTH_PRM_DISCOVERY: { + id: 'MCP-2025-06-18-prm-discovery', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location' + }, + MCP_AUTH_SERVER_METADATA: { + id: 'MCP-2025-06-18-server-metadata', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#server-metadata-discovery' + }, + MCP_AUTH_DCR: { + id: 'MCP-2025-06-18-dcr', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#dynamic-client-registration' + }, + MCP_AUTH_ACCESS_TOKEN: { + id: 'MCP-2025-06-18-access-token', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#access-token-usage' + }, + + // ───────────────────────────────────────────────────────────────────────── + // MCP Extension: Client Credentials (SEP-1046) + // ───────────────────────────────────────────────────────────────────────── + SEP_1046_CLIENT_CREDENTIALS: { + id: 'SEP-1046-client-credentials', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx' + } +}; From 704b4a0a5d6d5fbcad60a0fd377984ecbad2c93c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 11:23:03 +0000 Subject: [PATCH 02/12] fix: auth-test-server outputs full /mcp endpoint URL The runner regex expects the server to output the full MCP endpoint URL. Updated auth-test-server to output http://localhost:PORT/mcp and the runner to match URLs with /mcp suffix. --- examples/servers/typescript/auth-test-server.ts | 3 +-- src/runner/server.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/servers/typescript/auth-test-server.ts b/examples/servers/typescript/auth-test-server.ts index deeca51..4868e91 100644 --- a/examples/servers/typescript/auth-test-server.ts +++ b/examples/servers/typescript/auth-test-server.ts @@ -297,8 +297,7 @@ app.delete( // Start server app.listen(PORT, () => { - console.log(`MCP Auth Test Server running on http://localhost:${PORT}`); - console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); + console.log(`MCP Auth Test Server running at http://localhost:${PORT}/mcp`); console.log( ` - PRM endpoint: http://localhost:${PORT}/.well-known/oauth-protected-resource` ); diff --git a/src/runner/server.ts b/src/runner/server.ts index 2365e88..d2aebb6 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -227,7 +227,7 @@ export async function runServerAuthConformanceTest(options: { // Look for URL in server output const checkOutput = () => { - const urlMatch = serverOutput.match(/https?:\/\/localhost:\d+/); + const urlMatch = serverOutput.match(/https?:\/\/localhost:\d+\/mcp/); if (urlMatch) { clearTimeout(timeoutId); resolve(urlMatch[0]); From 390e0549a756a6d621faaf70830323b8801bf8c7 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 11:55:12 +0000 Subject: [PATCH 03/12] refactor: use SDK requireBearerAuth with token introspection - Add /introspect endpoint to fake auth server (RFC 7662) - Replace custom bearer auth middleware with SDK's requireBearerAuth - Auth-test-server now fetches AS metadata and uses introspection endpoint - Removes ~50 lines of custom auth code in favor of SDK primitives --- .../servers/typescript/auth-test-server.ts | 369 ++++++++++-------- .../client/auth/helpers/createAuthServer.ts | 47 ++- 2 files changed, 249 insertions(+), 167 deletions(-) diff --git a/examples/servers/typescript/auth-test-server.ts b/examples/servers/typescript/auth-test-server.ts index 4868e91..727a9ea 100644 --- a/examples/servers/typescript/auth-test-server.ts +++ b/examples/servers/typescript/auth-test-server.ts @@ -15,8 +15,16 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + requireBearerAuth, + InvalidTokenError +} from '@modelcontextprotocol/sdk/server/auth.js'; +import type { + OAuthTokenVerifier, + AuthInfo +} from '@modelcontextprotocol/sdk/server/auth.js'; import { z } from 'zod'; -import express, { Request, Response, NextFunction } from 'express'; +import express, { Request, Response } from 'express'; import cors from 'cors'; import { randomUUID } from 'crypto'; @@ -85,63 +93,58 @@ function createMcpServer(): McpServer { } /** - * Validates a Bearer token. - * Accepts tokens that start with 'test-token' or 'cc-token' (as issued by the fake auth server). + * Fetches the authorization server metadata to get the introspection endpoint. */ -function isValidToken(token: string): boolean { - return token.startsWith('test-token') || token.startsWith('cc-token'); +async function fetchAuthServerMetadata(): Promise<{ + introspection_endpoint?: string; +}> { + const metadataUrl = `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`; + const response = await fetch(metadataUrl); + if (!response.ok) { + throw new Error(`Failed to fetch AS metadata: ${response.status}`); + } + return response.json(); } /** - * Bearer authentication middleware. - * Returns 401 with WWW-Authenticate header if token is missing or invalid. + * Creates a token verifier that uses the authorization server's introspection endpoint. */ -function bearerAuthMiddleware( - req: Request, - res: Response, - next: NextFunction -): void { - const authHeader = req.headers.authorization; - - // Check for Authorization header - if (!authHeader) { - sendUnauthorized(res, 'Missing authorization header'); - return; - } - - // Check for Bearer scheme - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { - sendUnauthorized(res, 'Invalid authorization scheme'); - return; - } - - const token = parts[1]; - - // Validate the token - if (!isValidToken(token)) { - sendUnauthorized(res, 'Invalid token'); - return; - } +function createIntrospectionVerifier( + introspectionEndpoint: string +): OAuthTokenVerifier { + return { + async verifyAccessToken(token: string): Promise { + const response = await fetch(introspectionEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ token }).toString() + }); - // Token is valid, proceed - next(); -} + if (!response.ok) { + throw new InvalidTokenError('Token introspection failed'); + } -/** - * Sends a 401 Unauthorized response with proper WWW-Authenticate header. - */ -function sendUnauthorized(res: Response, error: string): void { - const prmUrl = `${getBaseUrl()}/.well-known/oauth-protected-resource`; + const data = (await response.json()) as { + active: boolean; + client_id?: string; + scope?: string; + exp?: number; + }; - // Build WWW-Authenticate header with resource_metadata parameter - const wwwAuthenticate = `Bearer realm="mcp", error="invalid_token", error_description="${error}", resource_metadata="${prmUrl}"`; + if (!data.active) { + throw new InvalidTokenError('Token is not active'); + } - res.setHeader('WWW-Authenticate', wwwAuthenticate); - res.status(401).json({ - error: 'unauthorized', - error_description: error - }); + return { + token, + clientId: data.client_id || 'unknown', + scopes: data.scope ? data.scope.split(' ') : [], + expiresAt: data.exp || Math.floor(Date.now() / 1000) + 3600 + }; + } + }; } // Helper to check if request is an initialize request @@ -151,127 +154,154 @@ function isInitializeRequest(body: any): boolean { // ===== EXPRESS APP ===== -const app = express(); -app.use(express.json()); - -// Configure CORS to expose Mcp-Session-Id header for browser-based clients -app.use( - cors({ - origin: '*', - exposedHeaders: ['Mcp-Session-Id'], - allowedHeaders: [ - 'Content-Type', - 'mcp-session-id', - 'last-event-id', - 'Authorization' - ] - }) -); - -// Protected Resource Metadata endpoint (RFC 9728) -app.get( - '/.well-known/oauth-protected-resource', - (_req: Request, res: Response) => { - res.json({ - resource: getBaseUrl(), - authorization_servers: [AUTH_SERVER_URL] - }); +async function startServer() { + // Fetch AS metadata to get introspection endpoint + console.log( + `Fetching authorization server metadata from ${AUTH_SERVER_URL}...` + ); + const asMetadata = await fetchAuthServerMetadata(); + + if (!asMetadata.introspection_endpoint) { + console.error( + 'Error: Authorization server does not provide introspection_endpoint' + ); + process.exit(1); } -); - -// Handle POST requests to /mcp with bearer auth -app.post('/mcp', bearerAuthMiddleware, async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - try { - let transport: StreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport for established sessions - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // Create new transport for initialization requests - const mcpServer = createMcpServer(); - - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (newSessionId) => { - transports[newSessionId] = transport; - servers[newSessionId] = mcpServer; - console.log(`Session initialized with ID: ${newSessionId}`); - } - }); - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - delete transports[sid]; - if (servers[sid]) { - servers[sid].close(); - delete servers[sid]; - } - console.log(`Session ${sid} closed`); - } - }; + console.log( + `Using introspection endpoint: ${asMetadata.introspection_endpoint}` + ); - await mcpServer.connect(transport); - await transport.handleRequest(req, res, req.body); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Invalid or missing session ID' - }, - id: null + // Create token verifier that calls the introspection endpoint + const tokenVerifier = createIntrospectionVerifier( + asMetadata.introspection_endpoint + ); + + // Create bearer auth middleware using SDK + const prmUrl = `${getBaseUrl()}/.well-known/oauth-protected-resource`; + const bearerAuth = requireBearerAuth({ + verifier: tokenVerifier, + resourceMetadataUrl: prmUrl + }); + + const app = express(); + app.use(express.json()); + + // Configure CORS to expose Mcp-Session-Id header for browser-based clients + app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id'], + allowedHeaders: [ + 'Content-Type', + 'mcp-session-id', + 'last-event-id', + 'Authorization' + ] + }) + ); + + // Protected Resource Metadata endpoint (RFC 9728) + app.get( + '/.well-known/oauth-protected-resource', + (_req: Request, res: Response) => { + res.json({ + resource: getBaseUrl(), + authorization_servers: [AUTH_SERVER_URL] }); - 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: -32603, - message: 'Internal server error' - }, - id: null - }); + // Handle POST requests to /mcp with bearer auth + app.post('/mcp', bearerAuth, async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport for established sessions + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // Create new transport for initialization requests + const mcpServer = createMcpServer(); + + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (newSessionId) => { + transports[newSessionId] = transport; + servers[newSessionId] = mcpServer; + console.log(`Session initialized with ID: ${newSessionId}`); + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + if (servers[sid]) { + servers[sid].close(); + delete servers[sid]; + } + console.log(`Session ${sid} closed`); + } + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Invalid or missing session ID' + }, + 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: -32603, + message: 'Internal server error' + }, + id: null + }); + } } - } -}); + }); -// Handle GET requests - SSE streams for sessions (also requires auth) -app.get('/mcp', bearerAuthMiddleware, async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; + // Handle GET requests - SSE streams for sessions (also requires auth) + app.get('/mcp', bearerAuth, 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; - } + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } - console.log(`Establishing SSE stream for session ${sessionId}`); + console.log(`Establishing SSE stream for session ${sessionId}`); - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling SSE stream:', error); - if (!res.headersSent) { - res.status(500).send('Error establishing SSE stream'); + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } } - } -}); + }); -// Handle DELETE requests - session termination (also requires auth) -app.delete( - '/mcp', - bearerAuthMiddleware, - async (req: Request, res: Response) => { + // Handle DELETE requests - session termination (also requires auth) + app.delete('/mcp', bearerAuth, async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { @@ -292,14 +322,21 @@ app.delete( res.status(500).send('Error processing session termination'); } } - } -); + }); -// Start server -app.listen(PORT, () => { - console.log(`MCP Auth Test Server running at http://localhost:${PORT}/mcp`); - console.log( - ` - PRM endpoint: http://localhost:${PORT}/.well-known/oauth-protected-resource` - ); - console.log(` - Auth server: ${AUTH_SERVER_URL}`); + // Start server + app.listen(PORT, () => { + console.log(`MCP Auth Test Server running at http://localhost:${PORT}/mcp`); + console.log( + ` - PRM endpoint: http://localhost:${PORT}/.well-known/oauth-protected-resource` + ); + console.log(` - Auth server: ${AUTH_SERVER_URL}`); + console.log(` - Introspection: ${asMetadata.introspection_endpoint}`); + }); +} + +// Start the server +startServer().catch((error) => { + console.error('Failed to start server:', error); + process.exit(1); }); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 1071828..6d5f715 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -77,7 +77,8 @@ export function createAuthServer( const authRoutes = { authorization_endpoint: `${routePrefix}/authorize`, token_endpoint: `${routePrefix}/token`, - registration_endpoint: `${routePrefix}/register` + registration_endpoint: `${routePrefix}/register`, + introspection_endpoint: `${routePrefix}/introspect` }; const app = express(); @@ -115,6 +116,7 @@ export function createAuthServer( authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`, + introspection_endpoint: `${getAuthBaseUrl()}${authRoutes.introspection_endpoint}`, response_types_supported: ['code'], grant_types_supported: grantTypesSupported, code_challenge_methods_supported: ['S256'], @@ -278,5 +280,48 @@ export function createAuthServer( }); }); + // Token introspection endpoint (RFC 7662) + app.post( + authRoutes.introspection_endpoint, + async (req: Request, res: Response) => { + const { token } = req.body; + + if (!token) { + res.status(400).json({ error: 'Token is required' }); + return; + } + + // If we have a tokenVerifier, use it to validate + if (tokenVerifier) { + try { + const tokenInfo = await tokenVerifier.verifyAccessToken(token); + res.json({ + active: true, + client_id: tokenInfo.clientId, + scope: tokenInfo.scopes.join(' '), + exp: tokenInfo.expiresAt + }); + return; + } catch { + res.json({ active: false }); + return; + } + } + + // Fallback: accept tokens with known prefixes + if (token.startsWith('test-token') || token.startsWith('cc-token')) { + res.json({ + active: true, + client_id: 'test-client', + scope: '', + exp: Math.floor(Date.now() / 1000) + 3600 + }); + return; + } + + res.json({ active: false }); + } + ); + return app; } From deda2e52ca4835e90720692aeb27a32ba95c1729 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 12:06:27 +0000 Subject: [PATCH 04/12] feat: default to CIMD for fake AS, support both CIMD and DCR in tests - Default clientIdMetadataDocumentSupported=true in fake auth server - Add cimd-client-id check when URL-based client ID is detected - Add clientMetadataUrl to ConformanceOAuthProvider for CIMD support - Update test expectations to accept either CIMD or DCR - Explicitly disable CIMD in token-endpoint-auth tests that require client_secret --- .../client/auth/discovery-metadata.ts | 6 +++- .../client/auth/helpers/createAuthServer.ts | 31 +++++++++++++++++-- .../client/auth/march-spec-backcompat.ts | 12 +++++-- .../client/auth/token-endpoint-auth.ts | 2 ++ .../server-auth/helpers/oauth-client.ts | 24 ++++++++++++-- 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 6fc09a8..db7e81b 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -176,10 +176,14 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { }, getChecks(): ConformanceCheck[] { + // Check if CIMD was used (URL-based client ID) + const usedCimd = checks.some((c) => c.id === 'cimd-client-id'); + const expectedSlugs = [ ...(isPathBasedPrm ? ['prm-pathbased-requested'] : []), 'authorization-server-metadata', - 'client-registration', + // Either CIMD or DCR should be used, not both + ...(usedCimd ? [] : ['client-registration']), 'authorization-request', 'token-request' ]; diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 6d5f715..c9b37ae 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -24,6 +24,10 @@ export interface AuthServerOptions { grantTypesSupported?: string[]; tokenEndpointAuthMethodsSupported?: string[]; tokenEndpointAuthSigningAlgValuesSupported?: string[]; + /** + * Whether to advertise support for Client ID Metadata Documents (CIMD/SEP-991). + * Defaults to true - CIMD is preferred over DCR when available. + */ clientIdMetadataDocumentSupported?: boolean; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { @@ -64,7 +68,8 @@ export function createAuthServer( grantTypesSupported = ['authorization_code', 'refresh_token'], tokenEndpointAuthMethodsSupported = ['none'], tokenEndpointAuthSigningAlgValuesSupported, - clientIdMetadataDocumentSupported, + // Default to true - CIMD is preferred over DCR + clientIdMetadataDocumentSupported = true, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -150,6 +155,28 @@ export function createAuthServer( app.get(authRoutes.authorization_endpoint, (req: Request, res: Response) => { const timestamp = new Date().toISOString(); + const clientId = req.query.client_id as string | undefined; + + // Check if client is using CIMD (URL-based client ID) + const isUrlBasedClientId = + clientId && + (clientId.startsWith('https://') || clientId.startsWith('http://')); + + if (isUrlBasedClientId) { + checks.push({ + id: 'cimd-client-id', + name: 'CIMDClientId', + description: + 'Client used URL-based client ID (CIMD/SEP-991) instead of DCR', + status: 'SUCCESS', + timestamp, + specReferences: [SpecReferences.MCP_DCR], + details: { + clientId + } + }); + } + checks.push({ id: 'authorization-request', name: 'AuthorizationRequest', @@ -168,7 +195,7 @@ export function createAuthServer( if (onAuthorizationRequest) { onAuthorizationRequest({ - clientId: req.query.client_id as string | undefined, + clientId, scope: scopeParam, timestamp }); diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index 4f0a5ae..e9f8184 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -42,9 +42,13 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { } getChecks(): ConformanceCheck[] { + // Check if CIMD was used (URL-based client ID) + const usedCimd = this.checks.some((c) => c.id === 'cimd-client-id'); + const expectedSlugs = [ 'authorization-server-metadata', - 'client-registration', + // Either CIMD or DCR should be used, not both + ...(usedCimd ? [] : ['client-registration']), 'authorization-request', 'token-request' ]; @@ -169,8 +173,12 @@ export class Auth20250326OEndpointFallbackScenario implements Scenario { } getChecks(): ConformanceCheck[] { + // Check if CIMD was used (URL-based client ID) + const usedCimd = this.checks.some((c) => c.id === 'cimd-client-id'); + const expectedSlugs = [ - 'client-registration', + // Either CIMD or DCR should be used, not both + ...(usedCimd ? [] : ['client-registration']), 'authorization-request', 'token-request' ]; diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 4203789..979987e 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -64,6 +64,8 @@ class TokenEndpointAuthScenario implements Scenario { const authApp = createAuthServer(this.checks, this.authServer.getUrl, { tokenVerifier, tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod], + // Disable CIMD to force DCR - we need client_secret for auth method testing + clientIdMetadataDocumentSupported: false, onTokenRequest: ({ authorizationHeader, body, timestamp }) => { const bodyClientSecret = body.client_secret; const actualMethod = detectAuthMethod( diff --git a/src/scenarios/server-auth/helpers/oauth-client.ts b/src/scenarios/server-auth/helpers/oauth-client.ts index d9777ed..c1fab29 100644 --- a/src/scenarios/server-auth/helpers/oauth-client.ts +++ b/src/scenarios/server-auth/helpers/oauth-client.ts @@ -189,12 +189,21 @@ export function createObservationMiddleware( }); } +/** + * Fixed client metadata URL for CIMD conformance tests. + * When server supports client_id_metadata_document_supported, this URL + * will be used as the client_id instead of doing dynamic registration. + */ +const DEFAULT_CIMD_CLIENT_METADATA_URL = + 'https://conformance-test.local/client-metadata.json'; + /** * Conformance OAuth client provider for testing. * * This provider: * - Stores client information and tokens in memory * - Handles auto-login by fetching the authorization URL and extracting the code from redirect + * - Uses CIMD (URL-based client IDs) by default when server supports it */ export class ConformanceOAuthProvider implements OAuthClientProvider { private _clientInformation?: OAuthClientInformationFull; @@ -202,10 +211,21 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { private _codeVerifier?: string; private _authCode?: string; + /** + * URL for Client ID Metadata Document (CIMD/SEP-991). + * When provided and server advertises client_id_metadata_document_supported, + * this URL will be used as the client_id instead of DCR. + */ + readonly clientMetadataUrl?: string; + constructor( private readonly _redirectUrl: string | URL, - private readonly _clientMetadata: OAuthClientMetadata - ) {} + private readonly _clientMetadata: OAuthClientMetadata, + options?: { clientMetadataUrl?: string } + ) { + this.clientMetadataUrl = + options?.clientMetadataUrl ?? DEFAULT_CIMD_CLIENT_METADATA_URL; + } get redirectUrl(): string | URL { return this._redirectUrl; From 6433ce178dbe20aa77f84ef571bd08ee2bf89617 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 12:09:23 +0000 Subject: [PATCH 05/12] chore: remove auth-test-server (moved to typescript-sdk) --- .../servers/typescript/auth-test-server.ts | 342 ------------------ 1 file changed, 342 deletions(-) delete mode 100644 examples/servers/typescript/auth-test-server.ts diff --git a/examples/servers/typescript/auth-test-server.ts b/examples/servers/typescript/auth-test-server.ts deleted file mode 100644 index 727a9ea..0000000 --- a/examples/servers/typescript/auth-test-server.ts +++ /dev/null @@ -1,342 +0,0 @@ -#!/usr/bin/env node - -/** - * MCP Auth Test Server - Conformance Test Server with Authentication - * - * A minimal MCP server that requires Bearer token authentication. - * This server is used for testing OAuth authentication flows in conformance tests. - * - * Required environment variables: - * - MCP_CONFORMANCE_AUTH_SERVER_URL: URL of the authorization server - * - * Optional environment variables: - * - PORT: Server port (default: 3001) - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - requireBearerAuth, - InvalidTokenError -} from '@modelcontextprotocol/sdk/server/auth.js'; -import type { - OAuthTokenVerifier, - AuthInfo -} from '@modelcontextprotocol/sdk/server/auth.js'; -import { z } from 'zod'; -import express, { Request, Response } from 'express'; -import cors from 'cors'; -import { randomUUID } from 'crypto'; - -// Check for required environment variable -const AUTH_SERVER_URL = process.env.MCP_CONFORMANCE_AUTH_SERVER_URL; -if (!AUTH_SERVER_URL) { - console.error( - 'Error: MCP_CONFORMANCE_AUTH_SERVER_URL environment variable is required' - ); - console.error( - 'Usage: MCP_CONFORMANCE_AUTH_SERVER_URL=http://localhost:3000 npx tsx auth-test-server.ts' - ); - process.exit(1); -} - -// Server configuration -const PORT = process.env.PORT || 3001; -const getBaseUrl = () => `http://localhost:${PORT}`; - -// Session management -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; -const servers: { [sessionId: string]: McpServer } = {}; - -// Function to create a new MCP server instance (one per session) -function createMcpServer(): McpServer { - const mcpServer = new McpServer( - { - name: 'mcp-auth-test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - // Simple echo tool for testing authenticated calls - mcpServer.tool( - 'echo', - 'Echoes back the provided message - used for testing authenticated calls', - { - message: z.string().optional().describe('The message to echo back') - }, - async (args: { message?: string }) => { - const message = args.message || 'No message provided'; - return { - content: [{ type: 'text', text: `Echo: ${message}` }] - }; - } - ); - - // Simple test tool with no arguments - mcpServer.tool( - 'test-tool', - 'A simple test tool that returns a success message', - {}, - async () => { - return { - content: [{ type: 'text', text: 'test' }] - }; - } - ); - - return mcpServer; -} - -/** - * Fetches the authorization server metadata to get the introspection endpoint. - */ -async function fetchAuthServerMetadata(): Promise<{ - introspection_endpoint?: string; -}> { - const metadataUrl = `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`; - const response = await fetch(metadataUrl); - if (!response.ok) { - throw new Error(`Failed to fetch AS metadata: ${response.status}`); - } - return response.json(); -} - -/** - * Creates a token verifier that uses the authorization server's introspection endpoint. - */ -function createIntrospectionVerifier( - introspectionEndpoint: string -): OAuthTokenVerifier { - return { - async verifyAccessToken(token: string): Promise { - const response = await fetch(introspectionEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ token }).toString() - }); - - if (!response.ok) { - throw new InvalidTokenError('Token introspection failed'); - } - - const data = (await response.json()) as { - active: boolean; - client_id?: string; - scope?: string; - exp?: number; - }; - - if (!data.active) { - throw new InvalidTokenError('Token is not active'); - } - - return { - token, - clientId: data.client_id || 'unknown', - scopes: data.scope ? data.scope.split(' ') : [], - expiresAt: data.exp || Math.floor(Date.now() / 1000) + 3600 - }; - } - }; -} - -// Helper to check if request is an initialize request -function isInitializeRequest(body: any): boolean { - return body?.method === 'initialize'; -} - -// ===== EXPRESS APP ===== - -async function startServer() { - // Fetch AS metadata to get introspection endpoint - console.log( - `Fetching authorization server metadata from ${AUTH_SERVER_URL}...` - ); - const asMetadata = await fetchAuthServerMetadata(); - - if (!asMetadata.introspection_endpoint) { - console.error( - 'Error: Authorization server does not provide introspection_endpoint' - ); - process.exit(1); - } - - console.log( - `Using introspection endpoint: ${asMetadata.introspection_endpoint}` - ); - - // Create token verifier that calls the introspection endpoint - const tokenVerifier = createIntrospectionVerifier( - asMetadata.introspection_endpoint - ); - - // Create bearer auth middleware using SDK - const prmUrl = `${getBaseUrl()}/.well-known/oauth-protected-resource`; - const bearerAuth = requireBearerAuth({ - verifier: tokenVerifier, - resourceMetadataUrl: prmUrl - }); - - const app = express(); - app.use(express.json()); - - // Configure CORS to expose Mcp-Session-Id header for browser-based clients - app.use( - cors({ - origin: '*', - exposedHeaders: ['Mcp-Session-Id'], - allowedHeaders: [ - 'Content-Type', - 'mcp-session-id', - 'last-event-id', - 'Authorization' - ] - }) - ); - - // Protected Resource Metadata endpoint (RFC 9728) - app.get( - '/.well-known/oauth-protected-resource', - (_req: Request, res: Response) => { - res.json({ - resource: getBaseUrl(), - authorization_servers: [AUTH_SERVER_URL] - }); - } - ); - - // Handle POST requests to /mcp with bearer auth - app.post('/mcp', bearerAuth, async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - try { - let transport: StreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport for established sessions - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // Create new transport for initialization requests - const mcpServer = createMcpServer(); - - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (newSessionId) => { - transports[newSessionId] = transport; - servers[newSessionId] = mcpServer; - console.log(`Session initialized with ID: ${newSessionId}`); - } - }); - - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - delete transports[sid]; - if (servers[sid]) { - servers[sid].close(); - delete servers[sid]; - } - console.log(`Session ${sid} closed`); - } - }; - - await mcpServer.connect(transport); - await transport.handleRequest(req, res, req.body); - return; - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Invalid or missing session ID' - }, - 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: -32603, - message: 'Internal server error' - }, - id: null - }); - } - } - }); - - // Handle GET requests - SSE streams for sessions (also requires auth) - app.get('/mcp', bearerAuth, 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; - } - - console.log(`Establishing SSE stream for session ${sessionId}`); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling SSE stream:', error); - if (!res.headersSent) { - res.status(500).send('Error establishing SSE stream'); - } - } - }); - - // Handle DELETE requests - session termination (also requires auth) - app.delete('/mcp', bearerAuth, 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; - } - - console.log( - `Received session termination request for session ${sessionId}` - ); - - try { - const transport = transports[sessionId]; - await transport.handleRequest(req, res); - } catch (error) { - console.error('Error handling termination:', error); - if (!res.headersSent) { - res.status(500).send('Error processing session termination'); - } - } - }); - - // Start server - app.listen(PORT, () => { - console.log(`MCP Auth Test Server running at http://localhost:${PORT}/mcp`); - console.log( - ` - PRM endpoint: http://localhost:${PORT}/.well-known/oauth-protected-resource` - ); - console.log(` - Auth server: ${AUTH_SERVER_URL}`); - console.log(` - Introspection: ${asMetadata.introspection_endpoint}`); - }); -} - -// Start the server -startServer().catch((error) => { - console.error('Failed to start server:', error); - process.exit(1); -}); From 55a09adf244af013ef2512bca3dc5513079cee8e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 14:22:02 +0000 Subject: [PATCH 06/12] fix: update spec references to 2025-11-25 and simplify fake-auth-server - Update MCP Authorization spec refs from 2025-06-18 to 2025-11-25 - Fix anchor links for updated spec headings - Simplify fake-auth-server.ts by using ServerLifecycle for all cases - Add optional port parameter to ServerLifecycle.start() - Add introspection endpoint to fake-auth-server output --- src/fake-auth-server.ts | 86 ++++++------------- .../client/auth/helpers/serverLifecycle.ts | 8 +- src/scenarios/server-auth/spec-references.ts | 24 +++--- 3 files changed, 41 insertions(+), 77 deletions(-) diff --git a/src/fake-auth-server.ts b/src/fake-auth-server.ts index b62d151..07febd5 100644 --- a/src/fake-auth-server.ts +++ b/src/fake-auth-server.ts @@ -5,6 +5,19 @@ import { createAuthServer } from './scenarios/client/auth/helpers/createAuthServ import { ServerLifecycle } from './scenarios/client/auth/helpers/serverLifecycle'; import type { ConformanceCheck } from './types'; +function printServerInfo(url: string): void { + console.log(`Fake Auth Server running at ${url}`); + console.log(''); + console.log('Endpoints:'); + console.log(` Metadata: ${url}/.well-known/oauth-authorization-server`); + console.log(` Authorization: ${url}/authorize`); + console.log(` Token: ${url}/token`); + console.log(` Registration: ${url}/register`); + console.log(` Introspection: ${url}/introspect`); + console.log(''); + console.log('Press Ctrl+C to stop'); +} + const program = new Command(); program @@ -16,70 +29,21 @@ program .action(async (options) => { const port = parseInt(options.port, 10); const checks: ConformanceCheck[] = []; + const lifecycle = new ServerLifecycle(); - // If a specific port is requested, we need to handle URL differently - if (port !== 0) { - // For fixed port, we need to track the URL ourselves since we're not using lifecycle.start() - let serverUrl = ''; - const getUrl = () => serverUrl; - - const app = createAuthServer(checks, getUrl, { - loggingEnabled: true - }); - - const httpServer = app.listen(port, () => { - const address = httpServer.address(); - const actualPort = - typeof address === 'object' && address ? address.port : port; - serverUrl = `http://localhost:${actualPort}`; - console.log(`Fake Auth Server running at ${serverUrl}`); - console.log(''); - console.log('Endpoints:'); - console.log( - ` Metadata: ${serverUrl}/.well-known/oauth-authorization-server` - ); - console.log(` Authorization: ${serverUrl}/authorize`); - console.log(` Token: ${serverUrl}/token`); - console.log(` Registration: ${serverUrl}/register`); - console.log(''); - console.log('Press Ctrl+C to stop'); - }); - - // Handle graceful shutdown - process.on('SIGINT', () => { - console.log('\nShutting down...'); - httpServer.close(() => { - process.exit(0); - }); - }); - } else { - // Use ServerLifecycle for random port assignment - const lifecycle = new ServerLifecycle(); - - const app = createAuthServer(checks, lifecycle.getUrl, { - loggingEnabled: true - }); + const app = createAuthServer(checks, lifecycle.getUrl, { + loggingEnabled: true + }); - const url = await lifecycle.start(app); - console.log(`Fake Auth Server running at ${url}`); - console.log(''); - console.log('Endpoints:'); - console.log( - ` Metadata: ${url}/.well-known/oauth-authorization-server` - ); - console.log(` Authorization: ${url}/authorize`); - console.log(` Token: ${url}/token`); - console.log(` Registration: ${url}/register`); - console.log(''); - console.log('Press Ctrl+C to stop'); + const url = await lifecycle.start(app, port !== 0 ? port : undefined); + printServerInfo(url); - // Handle graceful shutdown - process.on('SIGINT', async () => { - console.log('\nShutting down...'); - await lifecycle.stop(); - process.exit(0); - }); - } + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('\nShutting down...'); + await lifecycle.stop(); + process.exit(0); + }); }); program.parse(); diff --git a/src/scenarios/client/auth/helpers/serverLifecycle.ts b/src/scenarios/client/auth/helpers/serverLifecycle.ts index 7fb3886..7491531 100644 --- a/src/scenarios/client/auth/helpers/serverLifecycle.ts +++ b/src/scenarios/client/auth/helpers/serverLifecycle.ts @@ -10,11 +10,11 @@ export class ServerLifecycle { return this.baseUrl; }; - async start(app: express.Application): Promise { + async start(app: express.Application, port?: number): Promise { this.app = app; - this.httpServer = this.app.listen(0); - const port = this.httpServer.address().port; - this.baseUrl = `http://localhost:${port}`; + this.httpServer = this.app.listen(port ?? 0); + const actualPort = this.httpServer.address().port; + this.baseUrl = `http://localhost:${actualPort}`; return this.baseUrl; } diff --git a/src/scenarios/server-auth/spec-references.ts b/src/scenarios/server-auth/spec-references.ts index ad99169..e9029b8 100644 --- a/src/scenarios/server-auth/spec-references.ts +++ b/src/scenarios/server-auth/spec-references.ts @@ -7,7 +7,7 @@ * - RFC 7591 (Dynamic Client Registration) * - RFC 6750 (Bearer Token Usage) * - OAuth 2.1 Draft (Client Credentials, Token Endpoint Auth) - * - MCP Authorization Specification (2025-06-18) + * - MCP Authorization Specification (2025-11-25) */ import { SpecReference } from '../../types'; @@ -94,27 +94,27 @@ export const ServerAuthSpecReferences: { [key: string]: SpecReference } = { }, // ───────────────────────────────────────────────────────────────────────── - // MCP Authorization Specification (2025-06-18) + // MCP Authorization Specification (2025-11-25) // ───────────────────────────────────────────────────────────────────────── MCP_AUTH_SERVER_LOCATION: { - id: 'MCP-2025-06-18-server-location', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location' + id: 'MCP-2025-11-25-server-location', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-location' }, MCP_AUTH_PRM_DISCOVERY: { - id: 'MCP-2025-06-18-prm-discovery', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location' + id: 'MCP-2025-11-25-prm-discovery', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements' }, MCP_AUTH_SERVER_METADATA: { - id: 'MCP-2025-06-18-server-metadata', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#server-metadata-discovery' + id: 'MCP-2025-11-25-server-metadata', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery' }, MCP_AUTH_DCR: { - id: 'MCP-2025-06-18-dcr', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#dynamic-client-registration' + id: 'MCP-2025-11-25-dcr', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration' }, MCP_AUTH_ACCESS_TOKEN: { - id: 'MCP-2025-06-18-access-token', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#access-token-usage' + id: 'MCP-2025-11-25-access-token', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage' }, // ───────────────────────────────────────────────────────────────────────── From cf05bd63333b8effbe9b7613fbad30205cf991dd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 14:28:13 +0000 Subject: [PATCH 07/12] refactor: use --url and --command for auth tests instead of separate flags - Remove --auth-url and --auth-command flags - Reuse existing --url and --command patterns for auth suite - --command with --suite auth spawns fake AS automatically - --url with --suite auth tests against already-running server --- src/index.ts | 30 ++++++++++++------------------ src/runner/server.ts | 28 ++++++++++++++-------------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0101bd3..3071a7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -202,14 +202,13 @@ program program .command('server') .description('Run conformance tests against a server implementation') - .option('--url ', 'URL of the server to test') .option( - '--auth-url ', - 'URL for auth testing (when server is already running)' + '--url ', + 'URL of the server to test (for already-running servers)' ) .option( - '--auth-command ', - 'Command to start the server (conformance will start fake AS and pass MCP_CONFORMANCE_AUTH_SERVER_URL)' + '--command ', + 'Command to start the server (for auth suite: spawns fake AS and passes MCP_CONFORMANCE_AUTH_SERVER_URL)' ) .option( '--scenario ', @@ -230,22 +229,17 @@ program // Check if this is an auth test const isAuthTest = - suite === 'auth' || - options.authUrl || - options.authCommand || - options.scenario?.startsWith('server-auth/'); + suite === 'auth' || options.scenario?.startsWith('server-auth/'); if (isAuthTest) { - // Auth testing mode - if (!options.authUrl && !options.authCommand) { + // Auth testing mode - requires --url or --command + if (!options.url && !options.command) { console.error( - 'For auth testing, either --auth-url or --auth-command is required' + 'For auth testing, either --url or --command is required' ); + console.error('\n--url URL of already running server'); console.error( - '\n--auth-url: URL of already running server to test auth against' - ); - console.error( - '--auth-command: Command to start the server (conformance will provide MCP_CONFORMANCE_AUTH_SERVER_URL)' + '--command Command to start the server (conformance spawns fake AS)' ); process.exit(1); } @@ -267,8 +261,8 @@ program console.log(`\n=== Running scenario: ${scenarioName} ===`); try { const result = await runServerAuthConformanceTest({ - authUrl: options.authUrl, - authCommand: options.authCommand, + url: options.url, + command: options.command, scenarioName, timeout }); diff --git a/src/runner/server.ts b/src/runner/server.ts index d2aebb6..7185095 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -152,14 +152,14 @@ async function waitForServerReady( /** * Run server auth conformance test * - * For --auth-command mode: Spawns the fake AS, then spawns the server with + * For --command mode: Spawns the fake AS, then spawns the server with * MCP_CONFORMANCE_AUTH_SERVER_URL env var pointing to the fake AS. * - * For --auth-url mode: Just runs the auth scenario against the provided URL. + * For --url mode: Just runs the auth scenario against the provided URL. */ export async function runServerAuthConformanceTest(options: { - authUrl?: string; - authCommand?: string; + url?: string; + command?: string; scenarioName: string; timeout?: number; }): Promise<{ @@ -167,7 +167,7 @@ export async function runServerAuthConformanceTest(options: { resultDir: string; scenarioDescription: string; }> { - const { authUrl, authCommand, scenarioName, timeout = 30000 } = options; + const { url, command, scenarioName, timeout = 30000 } = options; await ensureResultsDir(); const resultDir = createResultDir(scenarioName, 'server-auth'); @@ -184,8 +184,8 @@ export async function runServerAuthConformanceTest(options: { let authServerLifecycle: ServerLifecycle | null = null; try { - if (authCommand) { - // --auth-command mode: Start fake AS, then spawn server with env var + if (command) { + // --command mode: Start fake AS, then spawn server with env var console.log(`Starting fake authorization server...`); authServerLifecycle = new ServerLifecycle(); @@ -194,8 +194,8 @@ export async function runServerAuthConformanceTest(options: { console.log(`Fake AS running at ${authServerUrl}`); // Spawn the server command with the auth server URL env var - console.log(`Starting server with command: ${authCommand}`); - serverProcess = spawn(authCommand, { + console.log(`Starting server with command: ${command}`); + serverProcess = spawn(command, { shell: true, env: { ...process.env, @@ -263,15 +263,15 @@ export async function runServerAuthConformanceTest(options: { ); const scenarioChecks = await scenario.run(serverUrl); checks.push(...scenarioChecks); - } else if (authUrl) { - // --auth-url mode: Just run the scenario against the provided URL + } else if (url) { + // --url mode: Just run the scenario against the provided URL console.log( - `Running server auth scenario '${scenarioName}' against: ${authUrl}` + `Running server auth scenario '${scenarioName}' against: ${url}` ); - checks = await scenario.run(authUrl); + checks = await scenario.run(url); } else { throw new Error( - 'Either --auth-url or --auth-command must be provided for auth scenarios' + 'Either --url or --command must be provided for auth scenarios' ); } From 00bb580c9144236c0f517a02907a580afd7bc091 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 15:23:13 +0000 Subject: [PATCH 08/12] feat: add --interactive flag for browser-based OAuth flows Add support for testing servers that require browser-based login (like better-auth) instead of auto-redirect. When --interactive is specified: - Starts a callback server on port 3333 - Prints the authorization URL for the user to open in browser - Waits for the OAuth callback with the authorization code - Continues the auth flow once the code is received This enables testing third-party auth servers that use real login pages. --- src/index.ts | 7 +- src/runner/server.ts | 13 +- src/scenarios/server-auth/basic-dcr-flow.ts | 24 +++- .../server-auth/helpers/oauth-client.ts | 127 +++++++++++++++++- src/types.ts | 9 +- 5 files changed, 166 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3071a7f..afa7fd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -221,6 +221,10 @@ program ) .option('--timeout ', 'Timeout in milliseconds', '30000') .option('--verbose', 'Show verbose output (JSON instead of pretty print)') + .option( + '--interactive', + 'Interactive auth mode: opens browser for login instead of auto-redirect' + ) .action(async (options) => { try { const verbose = options.verbose ?? false; @@ -264,7 +268,8 @@ program url: options.url, command: options.command, scenarioName, - timeout + timeout, + interactive: options.interactive }); allResults.push({ scenario: scenarioName, checks: result.checks }); diff --git a/src/runner/server.ts b/src/runner/server.ts index 7185095..21fa139 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -162,12 +162,19 @@ export async function runServerAuthConformanceTest(options: { command?: string; scenarioName: string; timeout?: number; + interactive?: boolean; }): Promise<{ checks: ConformanceCheck[]; resultDir: string; scenarioDescription: string; }> { - const { url, command, scenarioName, timeout = 30000 } = options; + const { + url, + command, + scenarioName, + timeout = 30000, + interactive = false + } = options; await ensureResultsDir(); const resultDir = createResultDir(scenarioName, 'server-auth'); @@ -261,14 +268,14 @@ export async function runServerAuthConformanceTest(options: { console.log( `Running server auth scenario '${scenarioName}' against server: ${serverUrl}` ); - const scenarioChecks = await scenario.run(serverUrl); + const scenarioChecks = await scenario.run(serverUrl, { interactive }); checks.push(...scenarioChecks); } else if (url) { // --url mode: Just run the scenario against the provided URL console.log( `Running server auth scenario '${scenarioName}' against: ${url}` ); - checks = await scenario.run(url); + checks = await scenario.run(url, { interactive }); } else { throw new Error( 'Either --url or --command must be provided for auth scenarios' diff --git a/src/scenarios/server-auth/basic-dcr-flow.ts b/src/scenarios/server-auth/basic-dcr-flow.ts index c78a6b0..2888da5 100644 --- a/src/scenarios/server-auth/basic-dcr-flow.ts +++ b/src/scenarios/server-auth/basic-dcr-flow.ts @@ -13,7 +13,11 @@ * to verify server conformance. */ -import type { ClientScenario, ConformanceCheck } from '../../types'; +import type { + ClientScenario, + ClientScenarioOptions, + ConformanceCheck +} from '../../types'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { applyMiddlewares } from '@modelcontextprotocol/sdk/client/middleware.js'; @@ -52,10 +56,14 @@ export class BasicDcrFlowScenario implements ClientScenario { - RFC 6750 (Bearer Token Usage) - MCP Authorization Specification`; - async run(serverUrl: string): Promise { + async run( + serverUrl: string, + options?: ClientScenarioOptions + ): Promise { const checks: ConformanceCheck[] = []; const observedRequests: ObservedRequest[] = []; const timestamp = () => new Date().toISOString(); + const interactive = options?.interactive ?? false; // Create observation middleware to record all requests const observationMiddleware = createObservationMiddleware((req) => { @@ -63,15 +71,21 @@ export class BasicDcrFlowScenario implements ClientScenario { }); // Create OAuth provider for conformance testing + // In interactive mode, use port 3333 for the callback server + const callbackUrl = interactive + ? 'http://localhost:3333/callback' + : 'http://localhost:3000/callback'; + const provider = new ConformanceOAuthProvider( - 'http://localhost:3000/callback', + callbackUrl, { client_name: 'MCP Conformance Test Client', - redirect_uris: ['http://localhost:3000/callback'], + redirect_uris: [callbackUrl], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'client_secret_post' - } + }, + { interactive } ); // Handle 401 with OAuth flow diff --git a/src/scenarios/server-auth/helpers/oauth-client.ts b/src/scenarios/server-auth/helpers/oauth-client.ts index c1fab29..bca0021 100644 --- a/src/scenarios/server-auth/helpers/oauth-client.ts +++ b/src/scenarios/server-auth/helpers/oauth-client.ts @@ -4,8 +4,10 @@ * This module provides: * 1. A conformance-aware OAuthClientProvider that handles auto-login for testing * 2. An observation middleware that records all HTTP requests for conformance checks + * 3. Interactive mode support for servers that require browser-based login */ +import http from 'http'; import type { OAuthClientMetadata, OAuthClientInformationFull, @@ -15,6 +17,10 @@ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth. import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js'; import { createMiddleware } from '@modelcontextprotocol/sdk/client/middleware.js'; +/** Port for the OAuth callback server in interactive mode */ +const CALLBACK_PORT = 3333; +const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; + /** * Observed HTTP request/response for conformance checking. */ @@ -204,12 +210,15 @@ const DEFAULT_CIMD_CLIENT_METADATA_URL = * - Stores client information and tokens in memory * - Handles auto-login by fetching the authorization URL and extracting the code from redirect * - Uses CIMD (URL-based client IDs) by default when server supports it + * - Supports interactive mode for servers requiring browser-based login */ export class ConformanceOAuthProvider implements OAuthClientProvider { private _clientInformation?: OAuthClientInformationFull; private _tokens?: OAuthTokens; private _codeVerifier?: string; private _authCode?: string; + private _interactive: boolean; + private _callbackServer?: http.Server; /** * URL for Client ID Metadata Document (CIMD/SEP-991). @@ -221,14 +230,23 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { constructor( private readonly _redirectUrl: string | URL, private readonly _clientMetadata: OAuthClientMetadata, - options?: { clientMetadataUrl?: string } + options?: { clientMetadataUrl?: string; interactive?: boolean } ) { this.clientMetadataUrl = options?.clientMetadataUrl ?? DEFAULT_CIMD_CLIENT_METADATA_URL; + this._interactive = options?.interactive ?? false; + } + + /** + * Enable or disable interactive mode. + */ + setInteractive(interactive: boolean): void { + this._interactive = interactive; } get redirectUrl(): string | URL { - return this._redirectUrl; + // In interactive mode, use the callback server URL + return this._interactive ? CALLBACK_URL : this._redirectUrl; } get clientMetadata(): OAuthClientMetadata { @@ -253,9 +271,20 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { /** * Handle authorization redirect by fetching the URL and extracting auth code. - * This works with auto-login servers that redirect immediately with the code. + * In auto mode: fetches URL and expects immediate redirect with code. + * In interactive mode: starts callback server and waits for user to complete login in browser. */ async redirectToAuthorization(authorizationUrl: URL): Promise { + if (this._interactive) { + return this._interactiveAuthorization(authorizationUrl); + } + return this._autoAuthorization(authorizationUrl); + } + + /** + * Auto-login mode: fetch URL and extract code from redirect. + */ + private async _autoAuthorization(authorizationUrl: URL): Promise { try { const response = await fetch(authorizationUrl.toString(), { redirect: 'manual' // Don't follow redirects automatically @@ -264,7 +293,7 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { // Get the Location header which contains the redirect with auth code const location = response.headers.get('location'); if (location) { - const redirectUrl = new URL(location); + const redirectUrl = new URL(location, authorizationUrl); const code = redirectUrl.searchParams.get('code'); if (code) { this._authCode = code; @@ -281,6 +310,96 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { } } + /** + * Interactive mode: start callback server and wait for user to complete login. + */ + private async _interactiveAuthorization( + authorizationUrl: URL + ): Promise { + return new Promise((resolve, reject) => { + // Start callback server + this._callbackServer = http.createServer((req, res) => { + const url = new URL( + req.url || '/', + `http://localhost:${CALLBACK_PORT}` + ); + + if (url.pathname === '/callback') { + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end( + `

Authorization Error

${error}

` + ); + this._stopCallbackServer(); + reject(new Error(`Authorization error: ${error}`)); + return; + } + + if (code) { + this._authCode = code; + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end( + `

Authorization Successful!

You can close this window and return to the terminal.

` + ); + this._stopCallbackServer(); + resolve(); + return; + } + + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end( + `

Missing Code

No authorization code received.

` + ); + } else { + res.writeHead(404); + res.end('Not found'); + } + }); + + this._callbackServer.listen(CALLBACK_PORT, () => { + console.log(`\n${'='.repeat(70)}`); + console.log('INTERACTIVE AUTHORIZATION REQUIRED'); + console.log('='.repeat(70)); + console.log('\nOpen this URL in your browser to complete login:\n'); + console.log(` ${authorizationUrl.toString()}\n`); + console.log( + `Waiting for callback on http://localhost:${CALLBACK_PORT}/callback ...` + ); + console.log('='.repeat(70) + '\n'); + }); + + this._callbackServer.on('error', (err) => { + reject(new Error(`Callback server error: ${err.message}`)); + }); + + // Timeout after 5 minutes + setTimeout( + () => { + this._stopCallbackServer(); + reject( + new Error( + 'Authorization timeout - no callback received within 5 minutes' + ) + ); + }, + 5 * 60 * 1000 + ); + }); + } + + /** + * Stop the callback server if running. + */ + private _stopCallbackServer(): void { + if (this._callbackServer) { + this._callbackServer.close(); + this._callbackServer = undefined; + } + } + async getAuthCode(): Promise { if (this._authCode) { return this._authCode; diff --git a/src/types.ts b/src/types.ts index d5192b7..1084e7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,8 +41,15 @@ export interface Scenario { getChecks(): ConformanceCheck[]; } +export interface ClientScenarioOptions { + interactive?: boolean; +} + export interface ClientScenario { name: string; description: string; - run(serverUrl: string): Promise; + run( + serverUrl: string, + options?: ClientScenarioOptions + ): Promise; } From f1e255a4fcf3e7ad8b0a39729cc217eb060da849 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 16:13:06 +0000 Subject: [PATCH 09/12] refactor: simplify callback URL handling - Remove callback URL as constructor parameter - Provider now handles redirect_uris internally via clientMetadata getter - CALLBACK_URL is now a private constant in oauth-client.ts --- src/scenarios/server-auth/basic-dcr-flow.ts | 7 ---- .../server-auth/helpers/oauth-client.ts | 39 +++++++------------ 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/src/scenarios/server-auth/basic-dcr-flow.ts b/src/scenarios/server-auth/basic-dcr-flow.ts index 2888da5..0a5d45e 100644 --- a/src/scenarios/server-auth/basic-dcr-flow.ts +++ b/src/scenarios/server-auth/basic-dcr-flow.ts @@ -71,16 +71,9 @@ export class BasicDcrFlowScenario implements ClientScenario { }); // Create OAuth provider for conformance testing - // In interactive mode, use port 3333 for the callback server - const callbackUrl = interactive - ? 'http://localhost:3333/callback' - : 'http://localhost:3000/callback'; - const provider = new ConformanceOAuthProvider( - callbackUrl, { client_name: 'MCP Conformance Test Client', - redirect_uris: [callbackUrl], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'client_secret_post' diff --git a/src/scenarios/server-auth/helpers/oauth-client.ts b/src/scenarios/server-auth/helpers/oauth-client.ts index bca0021..b8abf17 100644 --- a/src/scenarios/server-auth/helpers/oauth-client.ts +++ b/src/scenarios/server-auth/helpers/oauth-client.ts @@ -17,10 +17,6 @@ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth. import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js'; import { createMiddleware } from '@modelcontextprotocol/sdk/client/middleware.js'; -/** Port for the OAuth callback server in interactive mode */ -const CALLBACK_PORT = 3333; -const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; - /** * Observed HTTP request/response for conformance checking. */ @@ -203,6 +199,9 @@ export function createObservationMiddleware( const DEFAULT_CIMD_CLIENT_METADATA_URL = 'https://conformance-test.local/client-metadata.json'; +/** Callback URL for OAuth redirects */ +const CALLBACK_URL = 'http://localhost:3333/callback'; + /** * Conformance OAuth client provider for testing. * @@ -228,7 +227,6 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { readonly clientMetadataUrl?: string; constructor( - private readonly _redirectUrl: string | URL, private readonly _clientMetadata: OAuthClientMetadata, options?: { clientMetadataUrl?: string; interactive?: boolean } ) { @@ -237,20 +235,15 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { this._interactive = options?.interactive ?? false; } - /** - * Enable or disable interactive mode. - */ - setInteractive(interactive: boolean): void { - this._interactive = interactive; - } - - get redirectUrl(): string | URL { - // In interactive mode, use the callback server URL - return this._interactive ? CALLBACK_URL : this._redirectUrl; + get redirectUrl(): string { + return CALLBACK_URL; } get clientMetadata(): OAuthClientMetadata { - return this._clientMetadata; + return { + ...this._clientMetadata, + redirect_uris: [CALLBACK_URL] + }; } clientInformation(): OAuthClientInformationFull | undefined { @@ -316,13 +309,13 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { private async _interactiveAuthorization( authorizationUrl: URL ): Promise { + const callbackUrl = new URL(CALLBACK_URL); + const port = parseInt(callbackUrl.port, 10); + return new Promise((resolve, reject) => { // Start callback server this._callbackServer = http.createServer((req, res) => { - const url = new URL( - req.url || '/', - `http://localhost:${CALLBACK_PORT}` - ); + const url = new URL(req.url || '/', callbackUrl.origin); if (url.pathname === '/callback') { const code = url.searchParams.get('code'); @@ -359,15 +352,13 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { } }); - this._callbackServer.listen(CALLBACK_PORT, () => { + this._callbackServer.listen(port, () => { console.log(`\n${'='.repeat(70)}`); console.log('INTERACTIVE AUTHORIZATION REQUIRED'); console.log('='.repeat(70)); console.log('\nOpen this URL in your browser to complete login:\n'); console.log(` ${authorizationUrl.toString()}\n`); - console.log( - `Waiting for callback on http://localhost:${CALLBACK_PORT}/callback ...` - ); + console.log(`Waiting for callback on ${CALLBACK_URL} ...`); console.log('='.repeat(70) + '\n'); }); From ac583cca9e98e7495ac03468f41890b59a2bef68 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 16:47:07 +0000 Subject: [PATCH 10/12] fix: add FAILURE checks for missing phases and expand AS metadata validation - Add FAILURE when PRM discovery is missing or lacks authorization_servers - Add FAILURE when AS metadata discovery is missing - Add FAILURE when token request is missing - Expand AS metadata validation to check: - issuer (required per RFC 8414) - authorization_endpoint (required for auth code flow) - token_endpoint (required) - code_challenge_methods_supported includes S256 (required for MCP PKCE) --- src/scenarios/server-auth/basic-dcr-flow.ts | 87 +++++++++++++++++++-- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/src/scenarios/server-auth/basic-dcr-flow.ts b/src/scenarios/server-auth/basic-dcr-flow.ts index 0a5d45e..994edd6 100644 --- a/src/scenarios/server-auth/basic-dcr-flow.ts +++ b/src/scenarios/server-auth/basic-dcr-flow.ts @@ -328,8 +328,31 @@ export class BasicDcrFlowScenario implements ClientScenario { authorizationServers: prm.authorization_servers } }); + } else { + checks.push({ + id: 'auth-prm-authorization-servers', + name: 'PRM Contains Authorization Servers', + description: + 'Protected Resource Metadata must include authorization_servers array', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ServerAuthSpecReferences.RFC_9728_PRM_RESPONSE] + }); } } + } else { + checks.push({ + id: 'auth-prm-discovery', + name: 'Protected Resource Metadata Discovery', + description: + 'No PRM discovery request observed - required for OAuth flow', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_9728_PRM_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_PRM_DISCOVERY + ] + }); } // Phase 3: AS Metadata Discovery @@ -363,25 +386,62 @@ export class BasicDcrFlowScenario implements ClientScenario { string, unknown >; + + // Required fields per RFC 8414 and MCP auth spec + const hasIssuer = !!metadata.issuer; + const hasAuthorizationEndpoint = !!metadata.authorization_endpoint; const hasTokenEndpoint = !!metadata.token_endpoint; - const hasRegistrationEndpoint = !!metadata.registration_endpoint; + const codeChallengeMethodsSupported = + metadata.code_challenge_methods_supported; + const supportsPkceS256 = + Array.isArray(codeChallengeMethodsSupported) && + codeChallengeMethodsSupported.includes('S256'); + + // Build list of missing/invalid fields + const issues = []; + if (!hasIssuer) issues.push('missing issuer'); + if (!hasAuthorizationEndpoint) + issues.push('missing authorization_endpoint'); + if (!hasTokenEndpoint) issues.push('missing token_endpoint'); + if (!supportsPkceS256) + issues.push('code_challenge_methods_supported must include S256'); + + const allValid = issues.length === 0; checks.push({ id: 'auth-as-metadata-fields', name: 'AS Metadata Required Fields', - description: - 'Authorization Server metadata includes required endpoints', - status: hasTokenEndpoint ? 'SUCCESS' : 'FAILURE', + description: allValid + ? 'Authorization Server metadata includes all required fields' + : `Authorization Server metadata issues: ${issues.join(', ')}`, + status: allValid ? 'SUCCESS' : 'FAILURE', timestamp: timestamp(), - specReferences: [ServerAuthSpecReferences.RFC_8414_AS_FIELDS], + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_FIELDS, + ServerAuthSpecReferences.MCP_AUTH_SERVER_METADATA + ], details: { - hasTokenEndpoint, - hasRegistrationEndpoint, + issuer: metadata.issuer, + authorizationEndpoint: metadata.authorization_endpoint, tokenEndpoint: metadata.token_endpoint, + codeChallengeMethodsSupported, registrationEndpoint: metadata.registration_endpoint } }); } + } else { + checks.push({ + id: 'auth-as-metadata-discovery', + name: 'Authorization Server Metadata Discovery', + description: + 'No AS metadata discovery request observed - required for OAuth flow', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.RFC_8414_AS_DISCOVERY, + ServerAuthSpecReferences.MCP_AUTH_SERVER_METADATA + ] + }); } // Phase 4: DCR Registration @@ -469,6 +529,19 @@ export class BasicDcrFlowScenario implements ClientScenario { } }); } + } else { + checks.push({ + id: 'auth-token-request', + name: 'Token Acquisition', + description: + 'No token request observed - required to complete OAuth flow', + status: 'FAILURE', + timestamp: timestamp(), + specReferences: [ + ServerAuthSpecReferences.OAUTH_2_1_TOKEN_REQUEST, + ServerAuthSpecReferences.MCP_AUTH_ACCESS_TOKEN + ] + }); } // Phase 6: Authenticated MCP Request From 345bf5356fd18fe4da7c6c987efa7186c0fbb61b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 17:04:52 +0000 Subject: [PATCH 11/12] fix: use Omit for clientMetadata type since redirect_uris is added in getter --- src/scenarios/server-auth/helpers/oauth-client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scenarios/server-auth/helpers/oauth-client.ts b/src/scenarios/server-auth/helpers/oauth-client.ts index b8abf17..7b60df6 100644 --- a/src/scenarios/server-auth/helpers/oauth-client.ts +++ b/src/scenarios/server-auth/helpers/oauth-client.ts @@ -227,7 +227,10 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { readonly clientMetadataUrl?: string; constructor( - private readonly _clientMetadata: OAuthClientMetadata, + private readonly _clientMetadata: Omit< + OAuthClientMetadata, + 'redirect_uris' + >, options?: { clientMetadataUrl?: string; interactive?: boolean } ) { this.clientMetadataUrl = From c34a01973623a66e8f3cf460d2c09ae472d0821f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 17:37:43 +0000 Subject: [PATCH 12/12] fix: use text/plain for callback responses to prevent XSS --- .../server-auth/helpers/oauth-client.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/scenarios/server-auth/helpers/oauth-client.ts b/src/scenarios/server-auth/helpers/oauth-client.ts index 7b60df6..1155392 100644 --- a/src/scenarios/server-auth/helpers/oauth-client.ts +++ b/src/scenarios/server-auth/helpers/oauth-client.ts @@ -325,10 +325,8 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { const error = url.searchParams.get('error'); if (error) { - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end( - `

Authorization Error

${error}

` - ); + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end(`Authorization Error: ${error}`); this._stopCallbackServer(); reject(new Error(`Authorization error: ${error}`)); return; @@ -336,19 +334,17 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { if (code) { this._authCode = code; - res.writeHead(200, { 'Content-Type': 'text/html' }); + res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end( - `

Authorization Successful!

You can close this window and return to the terminal.

` + 'Authorization successful! You can close this window and return to the terminal.' ); this._stopCallbackServer(); resolve(); return; } - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end( - `

Missing Code

No authorization code received.

` - ); + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing authorization code'); } else { res.writeHead(404); res.end('Not found');