Skip to content

Commit af815c6

Browse files
committed
feat: add auth-test-server for OAuth conformance testing
- MCP server with Bearer token authentication - Uses SDK's requireBearerAuth middleware - Validates tokens via AS introspection endpoint (RFC 7662) - Serves Protected Resource Metadata at /.well-known/oauth-protected-resource - Designed for server auth conformance tests
1 parent 5ce4b5e commit af815c6

2 files changed

Lines changed: 362 additions & 0 deletions

File tree

src/conformance/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ npx @modelcontextprotocol/conformance server \
5959

6060
- `everything-client.ts` - Client that handles all client conformance scenarios
6161
- `everything-server.ts` - Server that implements all server conformance features
62+
- `auth-test-server.ts` - Server with OAuth authentication for auth conformance tests
6263
- `helpers/` - Shared utilities for conformance tests
6364

6465
Scripts are in `scripts/` at the repo root.
66+
67+
## Auth Test Server
68+
69+
The `auth-test-server.ts` is designed for testing server-side OAuth implementation.
70+
It requires an authorization server URL and validates tokens via introspection.
71+
72+
```bash
73+
# Start with a fake auth server
74+
MCP_CONFORMANCE_AUTH_SERVER_URL=http://localhost:3000 \
75+
npx tsx src/conformance/auth-test-server.ts
76+
```
77+
78+
The server:
79+
- Requires Bearer token authentication on all MCP endpoints
80+
- Uses the SDK's `requireBearerAuth` middleware
81+
- Validates tokens via the AS's introspection endpoint (RFC 7662)
82+
- Serves Protected Resource Metadata at `/.well-known/oauth-protected-resource`
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* MCP Auth Test Server - Conformance Test Server with Authentication
5+
*
6+
* A minimal MCP server that requires Bearer token authentication.
7+
* This server is used for testing OAuth authentication flows in conformance tests.
8+
*
9+
* Required environment variables:
10+
* - MCP_CONFORMANCE_AUTH_SERVER_URL: URL of the authorization server
11+
*
12+
* Optional environment variables:
13+
* - PORT: Server port (default: 3001)
14+
*/
15+
16+
import { McpServer } from '@modelcontextprotocol/server';
17+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/server';
18+
import {
19+
requireBearerAuth,
20+
InvalidTokenError
21+
} from '@modelcontextprotocol/server';
22+
import type { OAuthTokenVerifier, AuthInfo } from '@modelcontextprotocol/server';
23+
import { z } from 'zod';
24+
import express, { Request, Response } from 'express';
25+
import cors from 'cors';
26+
import { randomUUID } from 'crypto';
27+
28+
// Check for required environment variable
29+
const AUTH_SERVER_URL = process.env.MCP_CONFORMANCE_AUTH_SERVER_URL;
30+
if (!AUTH_SERVER_URL) {
31+
console.error(
32+
'Error: MCP_CONFORMANCE_AUTH_SERVER_URL environment variable is required'
33+
);
34+
console.error(
35+
'Usage: MCP_CONFORMANCE_AUTH_SERVER_URL=http://localhost:3000 npx tsx auth-test-server.ts'
36+
);
37+
process.exit(1);
38+
}
39+
40+
// Server configuration
41+
const PORT = process.env.PORT || 3001;
42+
const getBaseUrl = () => `http://localhost:${PORT}`;
43+
44+
// Session management
45+
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
46+
const servers: { [sessionId: string]: McpServer } = {};
47+
48+
// Function to create a new MCP server instance (one per session)
49+
function createMcpServer(): McpServer {
50+
const mcpServer = new McpServer(
51+
{
52+
name: 'mcp-auth-test-server',
53+
version: '1.0.0'
54+
},
55+
{
56+
capabilities: {
57+
tools: {}
58+
}
59+
}
60+
);
61+
62+
// Simple echo tool for testing authenticated calls
63+
mcpServer.tool(
64+
'echo',
65+
'Echoes back the provided message - used for testing authenticated calls',
66+
{
67+
message: z.string().optional().describe('The message to echo back')
68+
},
69+
async (args: { message?: string }) => {
70+
const message = args.message || 'No message provided';
71+
return {
72+
content: [{ type: 'text', text: `Echo: ${message}` }]
73+
};
74+
}
75+
);
76+
77+
// Simple test tool with no arguments
78+
mcpServer.tool(
79+
'test-tool',
80+
'A simple test tool that returns a success message',
81+
{},
82+
async () => {
83+
return {
84+
content: [{ type: 'text', text: 'test' }]
85+
};
86+
}
87+
);
88+
89+
return mcpServer;
90+
}
91+
92+
/**
93+
* Fetches the authorization server metadata to get the introspection endpoint.
94+
*/
95+
async function fetchAuthServerMetadata(): Promise<{
96+
introspection_endpoint?: string;
97+
}> {
98+
const metadataUrl = `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`;
99+
const response = await fetch(metadataUrl);
100+
if (!response.ok) {
101+
throw new Error(`Failed to fetch AS metadata: ${response.status}`);
102+
}
103+
return response.json();
104+
}
105+
106+
/**
107+
* Creates a token verifier that uses the authorization server's introspection endpoint.
108+
*/
109+
function createIntrospectionVerifier(
110+
introspectionEndpoint: string
111+
): OAuthTokenVerifier {
112+
return {
113+
async verifyAccessToken(token: string): Promise<AuthInfo> {
114+
const response = await fetch(introspectionEndpoint, {
115+
method: 'POST',
116+
headers: {
117+
'Content-Type': 'application/x-www-form-urlencoded'
118+
},
119+
body: new URLSearchParams({ token }).toString()
120+
});
121+
122+
if (!response.ok) {
123+
throw new InvalidTokenError('Token introspection failed');
124+
}
125+
126+
const data = (await response.json()) as {
127+
active: boolean;
128+
client_id?: string;
129+
scope?: string;
130+
exp?: number;
131+
};
132+
133+
if (!data.active) {
134+
throw new InvalidTokenError('Token is not active');
135+
}
136+
137+
return {
138+
token,
139+
clientId: data.client_id || 'unknown',
140+
scopes: data.scope ? data.scope.split(' ') : [],
141+
expiresAt: data.exp || Math.floor(Date.now() / 1000) + 3600
142+
};
143+
}
144+
};
145+
}
146+
147+
// Helper to check if request is an initialize request
148+
function isInitializeRequest(body: unknown): boolean {
149+
return (
150+
typeof body === 'object' &&
151+
body !== null &&
152+
'method' in body &&
153+
(body as { method: string }).method === 'initialize'
154+
);
155+
}
156+
157+
// ===== EXPRESS APP =====
158+
159+
async function startServer() {
160+
// Fetch AS metadata to get introspection endpoint
161+
console.log(
162+
`Fetching authorization server metadata from ${AUTH_SERVER_URL}...`
163+
);
164+
const asMetadata = await fetchAuthServerMetadata();
165+
166+
if (!asMetadata.introspection_endpoint) {
167+
console.error(
168+
'Error: Authorization server does not provide introspection_endpoint'
169+
);
170+
process.exit(1);
171+
}
172+
173+
console.log(
174+
`Using introspection endpoint: ${asMetadata.introspection_endpoint}`
175+
);
176+
177+
// Create token verifier that calls the introspection endpoint
178+
const tokenVerifier = createIntrospectionVerifier(
179+
asMetadata.introspection_endpoint
180+
);
181+
182+
// Create bearer auth middleware using SDK
183+
const prmUrl = `${getBaseUrl()}/.well-known/oauth-protected-resource`;
184+
const bearerAuth = requireBearerAuth({
185+
verifier: tokenVerifier,
186+
resourceMetadataUrl: prmUrl
187+
});
188+
189+
const app = express();
190+
app.use(express.json());
191+
192+
// Configure CORS to expose Mcp-Session-Id header for browser-based clients
193+
app.use(
194+
cors({
195+
origin: '*',
196+
exposedHeaders: ['Mcp-Session-Id'],
197+
allowedHeaders: [
198+
'Content-Type',
199+
'mcp-session-id',
200+
'last-event-id',
201+
'Authorization'
202+
]
203+
})
204+
);
205+
206+
// Protected Resource Metadata endpoint (RFC 9728)
207+
app.get(
208+
'/.well-known/oauth-protected-resource',
209+
(_req: Request, res: Response) => {
210+
res.json({
211+
resource: getBaseUrl(),
212+
authorization_servers: [AUTH_SERVER_URL]
213+
});
214+
}
215+
);
216+
217+
// Handle POST requests to /mcp with bearer auth
218+
app.post('/mcp', bearerAuth, async (req: Request, res: Response) => {
219+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
220+
221+
try {
222+
let transport: StreamableHTTPServerTransport;
223+
224+
if (sessionId && transports[sessionId]) {
225+
// Reuse existing transport for established sessions
226+
transport = transports[sessionId];
227+
} else if (!sessionId && isInitializeRequest(req.body)) {
228+
// Create new transport for initialization requests
229+
const mcpServer = createMcpServer();
230+
231+
transport = new StreamableHTTPServerTransport({
232+
sessionIdGenerator: () => randomUUID(),
233+
onsessioninitialized: (newSessionId) => {
234+
transports[newSessionId] = transport;
235+
servers[newSessionId] = mcpServer;
236+
console.log(`Session initialized with ID: ${newSessionId}`);
237+
}
238+
});
239+
240+
transport.onclose = () => {
241+
const sid = transport.sessionId;
242+
if (sid && transports[sid]) {
243+
delete transports[sid];
244+
if (servers[sid]) {
245+
servers[sid].close();
246+
delete servers[sid];
247+
}
248+
console.log(`Session ${sid} closed`);
249+
}
250+
};
251+
252+
await mcpServer.connect(transport);
253+
await transport.handleRequest(req, res, req.body);
254+
return;
255+
} else {
256+
res.status(400).json({
257+
jsonrpc: '2.0',
258+
error: {
259+
code: -32000,
260+
message: 'Invalid or missing session ID'
261+
},
262+
id: null
263+
});
264+
return;
265+
}
266+
267+
await transport.handleRequest(req, res, req.body);
268+
} catch (error) {
269+
console.error('Error handling MCP request:', error);
270+
if (!res.headersSent) {
271+
res.status(500).json({
272+
jsonrpc: '2.0',
273+
error: {
274+
code: -32603,
275+
message: 'Internal server error'
276+
},
277+
id: null
278+
});
279+
}
280+
}
281+
});
282+
283+
// Handle GET requests - SSE streams for sessions (also requires auth)
284+
app.get('/mcp', bearerAuth, async (req: Request, res: Response) => {
285+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
286+
287+
if (!sessionId || !transports[sessionId]) {
288+
res.status(400).send('Invalid or missing session ID');
289+
return;
290+
}
291+
292+
console.log(`Establishing SSE stream for session ${sessionId}`);
293+
294+
try {
295+
const transport = transports[sessionId];
296+
await transport.handleRequest(req, res);
297+
} catch (error) {
298+
console.error('Error handling SSE stream:', error);
299+
if (!res.headersSent) {
300+
res.status(500).send('Error establishing SSE stream');
301+
}
302+
}
303+
});
304+
305+
// Handle DELETE requests - session termination (also requires auth)
306+
app.delete('/mcp', bearerAuth, async (req: Request, res: Response) => {
307+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
308+
309+
if (!sessionId || !transports[sessionId]) {
310+
res.status(400).send('Invalid or missing session ID');
311+
return;
312+
}
313+
314+
console.log(
315+
`Received session termination request for session ${sessionId}`
316+
);
317+
318+
try {
319+
const transport = transports[sessionId];
320+
await transport.handleRequest(req, res);
321+
} catch (error) {
322+
console.error('Error handling termination:', error);
323+
if (!res.headersSent) {
324+
res.status(500).send('Error processing session termination');
325+
}
326+
}
327+
});
328+
329+
// Start server
330+
app.listen(PORT, () => {
331+
console.log(`MCP Auth Test Server running at http://localhost:${PORT}/mcp`);
332+
console.log(
333+
` - PRM endpoint: http://localhost:${PORT}/.well-known/oauth-protected-resource`
334+
);
335+
console.log(` - Auth server: ${AUTH_SERVER_URL}`);
336+
console.log(` - Introspection: ${asMetadata.introspection_endpoint}`);
337+
});
338+
}
339+
340+
// Start the server
341+
startServer().catch((error) => {
342+
console.error('Failed to start server:', error);
343+
process.exit(1);
344+
});

0 commit comments

Comments
 (0)