feat: add auth-test-server for OAuth conformance testing (#1384)

Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
Paul Carleton 2026-03-23 16:49:21 +00:00 • committed by GitHub
commit 9aed95a79b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 437 additions and 4 deletions

View file

@ -60,8 +60,24 @@ npx @modelcontextprotocol/conformance server \
## Files
- `everything-client.ts` - Client that handles all client conformance scenarios
- `everything-server.ts` - Server that implements all server conformance features
- `helpers/` - Shared utilities for conformance tests
- `src/everythingClient.ts` - Client that handles all client conformance scenarios
- `src/everythingServer.ts` - Server that implements all server conformance features
- `src/authTestServer.ts` - Server with OAuth authentication for auth conformance tests
- `src/helpers/` - Shared utilities for conformance tests
- `scripts/` - Conformance test runner scripts
Scripts are in `scripts/` at the repo root.
## Auth Test Server
The `authTestServer.ts` is designed for testing server-side OAuth implementation. It requires an authorization server URL and validates tokens via introspection.
```bash
# Start with a fake auth server
MCP_CONFORMANCE_AUTH_SERVER_URL=http://localhost:3000 \
npx tsx src/authTestServer.ts
```
The server:
- Requires Bearer token authentication on all MCP endpoints
- Validates tokens via the AS's introspection endpoint (RFC 7662)
- Serves Protected Resource Metadata at `/.well-known/oauth-protected-resource`

View file

@ -0,0 +1,417 @@
#!/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 { randomUUID } from 'node:crypto';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { AuthInfo } from '@modelcontextprotocol/server';
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
import cors from 'cors';
import type { NextFunction, Request, Response } from 'express';
import express from 'express';
import * as z from 'zod/v4';
// Extend Express Request type to include auth info set by middleware
declare module 'express' {
interface Request {
auth?: AuthInfo;
}
}
// 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 authTestServer.ts');
process.exit(1);
}
// Server configuration
const PORT = process.env.PORT || 3001;
const getBaseUrl = () => `http://localhost:${PORT}`;
// Session management
const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {};
const servers: { [sessionId: string]: McpServer } = {};
// Scope required for admin-action tool
const ADMIN_SCOPE = 'admin';
// 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.registerTool(
'echo',
{
description: 'Echoes back the provided message - used for testing authenticated calls',
inputSchema: z.object({
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.registerTool(
'test-tool',
{
description: 'A simple test tool that returns a success message'
},
async () => {
return {
content: [{ type: 'text', text: 'test' }]
};
}
);
// Privileged tool requiring 'admin' scope - for step-up auth testing
mcpServer.registerTool(
'admin-action',
{
description: 'A privileged action that requires admin scope - used for step-up auth testing',
inputSchema: z.object({
action: z.string().optional().describe('The admin action to perform')
})
},
async (args: { action?: string }) => {
const action = args.action || 'default-admin-action';
return {
content: [{ type: 'text', text: `Admin action performed: ${action}` }]
};
}
);
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() as Promise<{ introspection_endpoint?: string }>;
}
/**
* Verifies a token via the authorization server's introspection endpoint (RFC 7662).
*/
async function introspectToken(introspectionEndpoint: string, token: string): Promise<AuthInfo> {
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 Error('Token introspection failed');
}
const data = (await response.json()) as {
active: boolean;
client_id?: string;
scope?: string;
exp?: number;
};
if (!data.active) {
throw new Error('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
};
}
/**
* Express middleware that requires a valid Bearer token.
* Validates via the authorization server's introspection endpoint and sets req.auth.
*/
function requireBearerAuth(introspectionEndpoint: string, prmUrl: string) {
const buildWwwAuthHeader = (errorCode: string, message: string): string => {
return `Bearer error="${errorCode}", error_description="${message}", resource_metadata="${prmUrl}"`;
};
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', 'Missing Authorization header'));
res.status(401).json({
error: 'invalid_token',
error_description: 'Missing Authorization header'
});
return;
}
const token = authHeader.slice(7); // Remove 'Bearer ' prefix
try {
req.auth = await introspectToken(introspectionEndpoint, token);
next();
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid token';
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', message));
res.status(401).json({
error: 'invalid_token',
error_description: message
});
}
};
}
// Helper to check if request is a tools/call for admin-action
function isAdminToolCall(body: unknown): boolean {
if (typeof body !== 'object' || body === null || !('method' in body) || !('params' in body)) {
return false;
}
const { method, params } = body as { method: string; params: unknown };
if (method !== 'tools/call') {
return false;
}
if (typeof params !== 'object' || params === null || !('name' in params)) {
return false;
}
return (params as { name: string }).name === 'admin-action';
}
/**
* Middleware to check for admin scope on privileged tool calls.
* Returns 403 insufficient_scope if the token doesn't have admin scope.
*/
function checkAdminScope(prmUrl: string) {
return (req: Request, res: Response, next: NextFunction): void => {
// Only check for tools/call with admin-action
if (!isAdminToolCall(req.body)) {
return next();
}
// req.auth is set by requireBearerAuth middleware
const scopes = req.auth?.scopes || [];
if (!scopes.includes(ADMIN_SCOPE)) {
// Return 403 with insufficient_scope error
res.setHeader(
'WWW-Authenticate',
`Bearer error="insufficient_scope", ` +
`scope="${ADMIN_SCOPE}", ` +
`resource_metadata="${prmUrl}", ` +
`error_description="The admin-action tool requires admin scope"`
);
res.status(403).json({
error: 'insufficient_scope',
error_description: 'The admin-action tool requires admin scope'
});
return;
}
next();
};
}
// ===== 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 bearer auth middleware
const prmUrl = `${getBaseUrl()}/.well-known/oauth-protected-resource`;
const bearerAuth = requireBearerAuth(asMetadata.introspection_endpoint, prmUrl);
// Create scope-checking middleware for privileged tools
const adminScopeCheck = checkAdminScope(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],
// List supported scopes for step-up auth testing
scopes_supported: [ADMIN_SCOPE]
});
});
// Handle POST requests to /mcp with bearer auth and scope checking
app.post('/mcp', bearerAuth, adminScopeCheck, async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
try {
let transport: NodeStreamableHTTPServerTransport;
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 NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId: string) => {
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: -32_000,
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: -32_603,
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
try {
await startServer();
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}