-
Notifications
You must be signed in to change notification settings - Fork 17
feat: Add server auth conformance tests #105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
pcarleton
wants to merge
12
commits into
main
Choose a base branch
from
server-auth-conformance
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 2 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
d7f09b6
wip server auth conformance first cut
pcarleton 704b4a0
fix: auth-test-server outputs full /mcp endpoint URL
pcarleton 390e054
refactor: use SDK requireBearerAuth with token introspection
pcarleton deda2e5
feat: default to CIMD for fake AS, support both CIMD and DCR in tests
pcarleton 6433ce1
chore: remove auth-test-server (moved to typescript-sdk)
pcarleton 55a09ad
fix: update spec references to 2025-11-25 and simplify fake-auth-server
pcarleton cf05bd6
refactor: use --url and --command for auth tests instead of separate …
pcarleton 00bb580
feat: add --interactive flag for browser-based OAuth flows
pcarleton f1e255a
refactor: simplify callback URL handling
pcarleton ac583cc
fix: add FAILURE checks for missing phases and expand AS metadata val…
pcarleton 345bf53
fix: use Omit for clientMetadata type since redirect_uris is added in…
pcarleton c34a019
fix: use text/plain for callback responses to prevent XSS
pcarleton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,305 @@ | ||
| #!/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 at http://localhost:${PORT}/mcp`); | ||
| console.log( | ||
| ` - PRM endpoint: http://localhost:${PORT}/.well-known/oauth-protected-resource` | ||
| ); | ||
| console.log(` - Auth server: ${AUTH_SERVER_URL}`); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.