feat: add conformance test infrastructure for v1.x (#1518)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>
This commit is contained in:
Felix Weinberger 2026-02-11 11:18:45 +00:00 • committed by GitHub
commit b0cf83716b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1734 additions and 15 deletions

View file

@ -0,0 +1,14 @@
# Known conformance test failures for v1.x
# These are tracked and should be removed as they're fixed.
#
# tools_call: conformance runner's test server reuses a single Server
# instance across requests, triggering v1.26.0's "Already connected"
# guard (GHSA-345p-7cg4-v4c7). Fixed in conformance repo (PR #141),
# remove this entry once a new conformance release is published.
#
# auth/pre-registration: scenario added in conformance 0.1.11 that
# requires a dedicated client handler for pre-registered credentials.
# Needs to be implemented in both v1.x and main.
client:
- tools_call
- auth/pre-registration

View file

@ -0,0 +1,45 @@
#!/bin/bash
# Script to run server conformance tests
# Starts the conformance server, runs conformance tests, then stops the server
set -e
PORT="${PORT:-3000}"
SERVER_URL="http://localhost:${PORT}/mcp"
# Navigate to repo root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/../../.."
# Start the server in the background
echo "Starting conformance test server on port ${PORT}..."
npx tsx test/conformance/src/everythingServer.ts &
SERVER_PID=$!
# Function to cleanup on exit
cleanup() {
echo "Stopping server (PID: ${SERVER_PID})..."
kill $SERVER_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
}
trap cleanup EXIT
# Wait for server to be ready
echo "Waiting for server to be ready..."
MAX_RETRIES=30
RETRY_COUNT=0
while ! curl -s "${SERVER_URL}" > /dev/null 2>&1; do
RETRY_COUNT=$((RETRY_COUNT + 1))
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
echo "Server failed to start after ${MAX_RETRIES} attempts"
exit 1
fi
sleep 0.5
done
echo "Server is ready. Running conformance tests..."
# Run conformance tests - pass through all arguments
npx @modelcontextprotocol/conformance server --url "${SERVER_URL}" "$@"
echo "Conformance tests completed."

View file

@ -0,0 +1,370 @@
#!/usr/bin/env node
/**
* Everything client (v1.x) - a single conformance test client that handles all scenarios.
*
* Usage: everything-client <server-url>
*
* The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable,
* which is set by the conformance test runner.
*/
import { Client } from '../../../src/client/index.js';
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '../../../src/client/auth-extensions.js';
import { ElicitRequestSchema } from '../../../src/types.js';
import { z } from 'zod';
import { logger } from './helpers/logger.js';
import { handle401, withOAuthRetry } from './helpers/withOAuthRetry.js';
/**
* Fixed client metadata URL for CIMD conformance tests.
*/
const CIMD_CLIENT_METADATA_URL = 'https://conformance-test.local/client-metadata.json';
/**
* Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT.
*/
const ClientConformanceContextSchema = z.discriminatedUnion('name', [
z.object({
name: z.literal('auth/client-credentials-jwt'),
client_id: z.string(),
private_key_pem: z.string(),
signing_algorithm: z.string().optional()
}),
z.object({
name: z.literal('auth/client-credentials-basic'),
client_id: z.string(),
client_secret: z.string()
})
]);
/**
* Parse the conformance context from MCP_CONFORMANCE_CONTEXT env var.
*/
function parseContext() {
const raw = process.env.MCP_CONFORMANCE_CONTEXT;
if (!raw) {
throw new Error('MCP_CONFORMANCE_CONTEXT not set');
}
return ClientConformanceContextSchema.parse(JSON.parse(raw));
}
// Scenario handler type
type ScenarioHandler = (serverUrl: string) => Promise<void>;
// Registry of scenario handlers
const scenarioHandlers: Record<string, ScenarioHandler> = {};
// Helper to register a scenario handler
function registerScenario(name: string, handler: ScenarioHandler): void {
scenarioHandlers[name] = handler;
}
// Helper to register multiple scenarios with the same handler
function registerScenarios(names: string[], handler: ScenarioHandler): void {
for (const name of names) {
scenarioHandlers[name] = handler;
}
}
// ============================================================================
// Basic scenarios (initialize, tools_call)
// ============================================================================
async function runBasicClient(serverUrl: string): Promise<void> {
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
logger.debug('Successfully connected to MCP server');
await client.listTools();
logger.debug('Successfully listed tools');
await transport.close();
logger.debug('Connection closed successfully');
}
// tools_call scenario needs to actually call a tool
async function runToolsCallClient(serverUrl: string): Promise<void> {
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
logger.debug('Successfully connected to MCP server');
const tools = await client.listTools();
logger.debug('Successfully listed tools');
// Call the add_numbers tool
const addTool = tools.tools.find(t => t.name === 'add_numbers');
if (addTool) {
const result = await client.callTool({
name: 'add_numbers',
arguments: { a: 5, b: 3 }
});
logger.debug('Tool call result:', JSON.stringify(result, null, 2));
}
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenario('initialize', runBasicClient);
registerScenario('tools_call', runToolsCallClient);
// ============================================================================
// Auth scenarios - well-behaved client
// ============================================================================
async function runAuthClient(serverUrl: string): Promise<void> {
const client = new Client({ name: 'test-auth-client', version: '1.0.0' }, { capabilities: {} });
const oauthFetch = withOAuthRetry('test-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);
logger.debug('Successfully connected to MCP server');
await client.listTools();
logger.debug('Successfully listed tools');
await client.callTool({ name: 'test-tool', arguments: {} });
logger.debug('Successfully called tool');
await transport.close();
logger.debug('Connection closed successfully');
}
// Register all auth scenarios that should use the well-behaved auth client
registerScenarios(
[
'auth/basic-cimd',
'auth/metadata-default',
'auth/metadata-var1',
'auth/metadata-var2',
'auth/metadata-var3',
'auth/2025-03-26-oauth-metadata-backcompat',
'auth/2025-03-26-oauth-endpoint-fallback',
'auth/scope-from-www-authenticate',
'auth/scope-from-scopes-supported',
'auth/scope-omitted-when-undefined',
'auth/scope-step-up',
'auth/scope-retry-limit',
'auth/token-endpoint-auth-basic',
'auth/token-endpoint-auth-post',
'auth/token-endpoint-auth-none'
],
runAuthClient
);
// ============================================================================
// Client Credentials scenarios
// ============================================================================
async function runClientCredentialsJwt(serverUrl: string): Promise<void> {
const ctx = parseContext();
if (ctx.name !== 'auth/client-credentials-jwt') {
throw new Error(`Expected jwt context, got ${ctx.name}`);
}
const provider = new PrivateKeyJwtProvider({
clientId: ctx.client_id,
privateKey: ctx.private_key_pem,
algorithm: ctx.signing_algorithm || 'ES256'
});
const client = new Client({ name: 'conformance-client-credentials-jwt', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
authProvider: provider
});
await client.connect(transport);
logger.debug('Successfully connected with private_key_jwt auth');
await client.listTools();
logger.debug('Successfully listed tools');
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenario('auth/client-credentials-jwt', runClientCredentialsJwt);
async function runClientCredentialsBasic(serverUrl: string): Promise<void> {
const ctx = parseContext();
if (ctx.name !== 'auth/client-credentials-basic') {
throw new Error(`Expected basic context, got ${ctx.name}`);
}
const provider = new ClientCredentialsProvider({
clientId: ctx.client_id,
clientSecret: ctx.client_secret
});
const client = new Client({ name: 'conformance-client-credentials-basic', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
authProvider: provider
});
await client.connect(transport);
logger.debug('Successfully connected with client_secret_basic auth');
await client.listTools();
logger.debug('Successfully listed tools');
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenario('auth/client-credentials-basic', runClientCredentialsBasic);
// ============================================================================
// Elicitation defaults scenario
// ============================================================================
async function runElicitationDefaultsClient(serverUrl: string): Promise<void> {
const client = new Client(
{ name: 'elicitation-defaults-test-client', version: '1.0.0' },
{
capabilities: {
elicitation: {
form: {
applyDefaults: true
}
}
}
}
);
// Register elicitation handler that returns empty content
// The SDK should fill in defaults for all omitted fields
client.setRequestHandler(ElicitRequestSchema, async request => {
logger.debug('Received elicitation request:', JSON.stringify(request.params, null, 2));
logger.debug('Accepting with empty content - SDK should apply defaults');
return {
action: 'accept' as const,
content: {}
};
});
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
logger.debug('Successfully connected to MCP server');
const tools = await client.listTools();
logger.debug(
'Available tools:',
tools.tools.map(t => t.name)
);
const testTool = tools.tools.find(t => t.name === 'test_client_elicitation_defaults');
if (!testTool) {
throw new Error('Test tool not found: test_client_elicitation_defaults');
}
logger.debug('Calling test_client_elicitation_defaults tool...');
const result = await client.callTool({
name: 'test_client_elicitation_defaults',
arguments: {}
});
logger.debug('Tool result:', JSON.stringify(result, null, 2));
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenario('elicitation-sep1034-client-defaults', runElicitationDefaultsClient);
// ============================================================================
// SSE retry scenario
// ============================================================================
async function runSSERetryClient(serverUrl: string): Promise<void> {
const client = new Client({ name: 'sse-retry-test-client', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
logger.debug('Successfully connected to MCP server');
const tools = await client.listTools();
logger.debug(
'Available tools:',
tools.tools.map(t => t.name)
);
const testTool = tools.tools.find(t => t.name === 'test_reconnection');
if (!testTool) {
throw new Error('Test tool not found: test_reconnection');
}
logger.debug('Calling test_reconnection tool...');
const result = await client.callTool({
name: 'test_reconnection',
arguments: {}
});
logger.debug('Tool result:', JSON.stringify(result, null, 2));
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenario('sse-retry', runSSERetryClient);
// ============================================================================
// Main entry point
// ============================================================================
async function main(): Promise<void> {
const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO;
const serverUrl = process.argv[2];
if (!scenarioName || !serverUrl) {
logger.error('Usage: MCP_CONFORMANCE_SCENARIO=<scenario> everything-client <server-url>');
logger.error('\nThe MCP_CONFORMANCE_SCENARIO env var is set automatically by the conformance runner.');
logger.error('\nAvailable scenarios:');
for (const name of Object.keys(scenarioHandlers).sort()) {
logger.error(` - ${name}`);
}
process.exit(1);
}
const handler = scenarioHandlers[scenarioName];
if (!handler) {
logger.error(`Unknown scenario: ${scenarioName}`);
logger.error('\nAvailable scenarios:');
for (const name of Object.keys(scenarioHandlers).sort()) {
logger.error(` - ${name}`);
}
process.exit(1);
}
try {
await handler(serverUrl);
process.exit(0);
} catch (error) {
logger.error('Error:', error);
process.exit(1);
}
}
try {
await main();
} catch (error) {
logger.error('Error:', error);
process.exit(1);
}

View file

@ -0,0 +1,953 @@
#!/usr/bin/env node
/**
* MCP Conformance Test Server (v1.x)
*
* Server implementing all MCP features for conformance testing.
* Adapted from the main branch version for the v1.x single-package SDK.
*/
import { randomUUID } from 'node:crypto';
import { StreamableHTTPServerTransport } from '../../../src/server/streamableHttp.js';
import type { EventId, EventStore, StreamId } from '../../../src/server/streamableHttp.js';
import { McpServer, ResourceTemplate } from '../../../src/server/mcp.js';
import type { CallToolResult, GetPromptResult, ReadResourceResult } from '../../../src/types.js';
import {
CompleteRequestSchema,
CreateMessageResultSchema,
ElicitResultSchema,
isInitializeRequest,
SetLevelRequestSchema,
SubscribeRequestSchema,
UnsubscribeRequestSchema
} from '../../../src/types.js';
import { localhostHostValidation } from '../../../src/server/middleware/hostHeaderValidation.js';
import cors from 'cors';
import type { Request, Response } from 'express';
import express from 'express';
import { z } from 'zod';
// Server state
const resourceSubscriptions = new Set<string>();
const watchedResourceContent = 'Watched resource content';
// Session management
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
const servers: { [sessionId: string]: McpServer } = {};
// In-memory event store for SEP-1699 resumability
const eventStoreData = new Map<string, { eventId: string; message: unknown; streamId: string }>();
function createEventStore(): EventStore {
return {
async storeEvent(streamId: StreamId, message: unknown): Promise<EventId> {
const eventId = `${streamId}::${Date.now()}_${randomUUID()}`;
eventStoreData.set(eventId, { eventId, message, streamId });
return eventId;
},
async replayEventsAfter(
lastEventId: EventId,
{ send }: { send: (eventId: EventId, message: unknown) => Promise<void> }
): Promise<StreamId> {
const streamId = lastEventId.split('::')[0] || lastEventId;
const eventsToReplay: Array<[string, { message: unknown }]> = [];
for (const [eventId, data] of eventStoreData.entries()) {
if (data.streamId === streamId && eventId > lastEventId) {
eventsToReplay.push([eventId, data]);
}
}
eventsToReplay.sort(([a], [b]) => a.localeCompare(b));
for (const [eventId, { message }] of eventsToReplay) {
if (message && typeof message === 'object' && Object.keys(message).length > 0) {
await send(eventId, message);
}
}
return streamId;
}
};
}
// Sample base64 encoded 1x1 red PNG pixel for testing
const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
// Sample base64 encoded minimal WAV file for testing
const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=';
// Function to create a new MCP server instance (one per session)
function createMcpServer() {
const mcpServer = new McpServer(
{
name: 'mcp-conformance-test-server',
version: '1.0.0'
},
{
capabilities: {
tools: {
listChanged: true
},
resources: {
subscribe: true,
listChanged: true
},
prompts: {
listChanged: true
},
logging: {},
completions: {}
}
}
);
// Helper to send log messages using the underlying server
function sendLog(
level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency',
message: string,
_data?: unknown
) {
mcpServer.server
.notification({
method: 'notifications/message',
params: {
level,
logger: 'conformance-test-server',
data: _data || message
}
})
.catch(() => {
// Ignore error if no client is connected
});
}
// ===== TOOLS =====
// Simple text tool
mcpServer.tool('test_simple_text', 'Tests simple text content response', async (): Promise<CallToolResult> => {
return {
content: [{ type: 'text', text: 'This is a simple text response for testing.' }]
};
});
// Image content tool
mcpServer.tool('test_image_content', 'Tests image content response', async (): Promise<CallToolResult> => {
return {
content: [{ type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }]
};
});
// Audio content tool
mcpServer.tool('test_audio_content', 'Tests audio content response', async (): Promise<CallToolResult> => {
return {
content: [{ type: 'audio', data: TEST_AUDIO_BASE64, mimeType: 'audio/wav' }]
};
});
// Embedded resource tool
mcpServer.tool('test_embedded_resource', 'Tests embedded resource content response', async (): Promise<CallToolResult> => {
return {
content: [
{
type: 'resource',
resource: {
uri: 'test://embedded-resource',
mimeType: 'text/plain',
text: 'This is an embedded resource content.'
}
}
]
};
});
// Multiple content types tool
mcpServer.tool(
'test_multiple_content_types',
'Tests response with multiple content types (text, image, resource)',
async (): Promise<CallToolResult> => {
return {
content: [
{ type: 'text', text: 'Multiple content types test:' },
{ type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' },
{
type: 'resource',
resource: {
uri: 'test://mixed-content-resource',
mimeType: 'application/json',
text: JSON.stringify({ test: 'data', value: 123 })
}
}
]
};
}
);
// Tool with logging
mcpServer.tool(
'test_tool_with_logging',
'Tests tool that emits log messages during execution',
{},
async (_args, extra): Promise<CallToolResult> => {
await extra.sendNotification({
method: 'notifications/message',
params: {
level: 'info',
data: 'Tool execution started'
}
});
await new Promise(resolve => setTimeout(resolve, 50));
await extra.sendNotification({
method: 'notifications/message',
params: {
level: 'info',
data: 'Tool processing data'
}
});
await new Promise(resolve => setTimeout(resolve, 50));
await extra.sendNotification({
method: 'notifications/message',
params: {
level: 'info',
data: 'Tool execution completed'
}
});
return {
content: [{ type: 'text', text: 'Tool with logging executed successfully' }]
};
}
);
// Tool with progress
mcpServer.tool(
'test_tool_with_progress',
'Tests tool that reports progress notifications',
{},
async (_args, extra): Promise<CallToolResult> => {
const progressToken = extra._meta?.progressToken ?? 0;
console.log('Progress token:', progressToken);
await extra.sendNotification({
method: 'notifications/progress',
params: {
progressToken,
progress: 0,
total: 100,
message: `Completed step ${0} of ${100}`
}
});
await new Promise(resolve => setTimeout(resolve, 50));
await extra.sendNotification({
method: 'notifications/progress',
params: {
progressToken,
progress: 50,
total: 100,
message: `Completed step ${50} of ${100}`
}
});
await new Promise(resolve => setTimeout(resolve, 50));
await extra.sendNotification({
method: 'notifications/progress',
params: {
progressToken,
progress: 100,
total: 100,
message: `Completed step ${100} of ${100}`
}
});
return {
content: [{ type: 'text', text: String(progressToken) }]
};
}
);
// Error handling tool
mcpServer.tool('test_error_handling', 'Tests error response handling', async (): Promise<CallToolResult> => {
throw new Error('This tool intentionally returns an error for testing');
});
// SEP-1699: Reconnection test tool - closes SSE stream mid-call to test client reconnection
mcpServer.tool(
'test_reconnection',
'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.',
{},
async (_args, extra): Promise<CallToolResult> => {
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
console.log(`[${extra.sessionId}] Starting test_reconnection tool...`);
// Get the transport for this session
const transport = extra.sessionId ? transports[extra.sessionId] : undefined;
if (transport && extra.requestId) {
// Close the SSE stream to trigger client reconnection
console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`);
transport.closeSSEStream(extra.requestId);
}
// Wait for client to reconnect (should respect retry field)
await sleep(100);
console.log(`[${extra.sessionId}] test_reconnection tool complete`);
return {
content: [
{
type: 'text',
text: 'Reconnection test completed successfully. If you received this, the client properly reconnected after stream closure.'
}
]
};
}
);
// Sampling tool - requests LLM completion from client
mcpServer.tool(
'test_sampling',
'Tests server-initiated sampling (LLM completion request)',
{
prompt: z.string()
},
async (args: { prompt: string }, extra): Promise<CallToolResult> => {
try {
// Request sampling from client
const result = (await extra.sendRequest(
{
method: 'sampling/createMessage',
params: {
messages: [
{
role: 'user',
content: {
type: 'text',
text: args.prompt
}
}
],
maxTokens: 100
}
},
CreateMessageResultSchema
)) as { content?: { text?: string }; message?: { content?: { text?: string } } };
const modelResponse = result.content?.text || result.message?.content?.text || 'No response';
return {
content: [
{
type: 'text',
text: `LLM response: ${modelResponse}`
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Sampling not supported or error: ${error instanceof Error ? error.message : String(error)}`
}
]
};
}
}
);
// Elicitation tool - requests user input from client
mcpServer.tool(
'test_elicitation',
'Tests server-initiated elicitation (user input request)',
{
message: z.string().describe('The message to show the user')
},
async (args: { message: string }, extra): Promise<CallToolResult> => {
try {
// Request user input from client
const result = await extra.sendRequest(
{
method: 'elicitation/create',
params: {
message: args.message,
requestedSchema: {
type: 'object',
properties: {
response: {
type: 'string',
description: "User's response"
}
},
required: ['response']
}
}
},
ElicitResultSchema
);
const elicitResult = result as { action?: string; content?: unknown };
return {
content: [
{
type: 'text',
text: `User response: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}`
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}`
}
]
};
}
}
);
// SEP-1034: Elicitation with default values for all primitive types
mcpServer.tool(
'test_elicitation_sep1034_defaults',
'Tests elicitation with default values per SEP-1034',
{},
async (_args, extra): Promise<CallToolResult> => {
try {
const result = await extra.sendRequest(
{
method: 'elicitation/create',
params: {
message: 'Please review and update the form fields with defaults',
requestedSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'User name',
default: 'John Doe'
},
age: {
type: 'integer',
description: 'User age',
default: 30
},
score: {
type: 'number',
description: 'User score',
default: 95.5
},
status: {
type: 'string',
description: 'User status',
enum: ['active', 'inactive', 'pending'],
default: 'active'
},
verified: {
type: 'boolean',
description: 'Verification status',
default: true
}
},
required: []
}
}
},
ElicitResultSchema
);
const elicitResult = result as { action?: string; content?: unknown };
return {
content: [
{
type: 'text',
text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}`
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}`
}
]
};
}
}
);
// SEP-1330: Elicitation with enum schema improvements
mcpServer.tool(
'test_elicitation_sep1330_enums',
'Tests elicitation with enum schema improvements per SEP-1330',
{},
async (_args, extra): Promise<CallToolResult> => {
try {
const result = await extra.sendRequest(
{
method: 'elicitation/create',
params: {
message: 'Please select options from the enum fields',
requestedSchema: {
type: 'object',
properties: {
untitledSingle: {
type: 'string',
description: 'Select one option',
enum: ['option1', 'option2', 'option3']
},
titledSingle: {
type: 'string',
description: 'Select one option with titles',
oneOf: [
{ const: 'value1', title: 'First Option' },
{ const: 'value2', title: 'Second Option' },
{ const: 'value3', title: 'Third Option' }
]
},
legacyEnum: {
type: 'string',
description: 'Select one option (legacy)',
enum: ['opt1', 'opt2', 'opt3'],
enumNames: ['Option One', 'Option Two', 'Option Three']
},
untitledMulti: {
type: 'array',
description: 'Select multiple options',
minItems: 1,
maxItems: 3,
items: {
type: 'string',
enum: ['option1', 'option2', 'option3']
}
},
titledMulti: {
type: 'array',
description: 'Select multiple options with titles',
minItems: 1,
maxItems: 3,
items: {
anyOf: [
{ const: 'value1', title: 'First Choice' },
{ const: 'value2', title: 'Second Choice' },
{ const: 'value3', title: 'Third Choice' }
]
}
}
},
required: []
}
}
},
ElicitResultSchema
);
const elicitResult = result as { action?: string; content?: unknown };
return {
content: [
{
type: 'text',
text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}`
}
]
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}`
}
]
};
}
}
);
// SEP-1613: JSON Schema 2020-12 conformance test tool
const addressSchema = z.object({
street: z.string().optional(),
city: z.string().optional()
});
mcpServer.tool(
'json_schema_2020_12_tool',
'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)',
{
name: z.string().optional(),
address: addressSchema.optional()
},
async (args: { name?: string; address?: { street?: string; city?: string } }): Promise<CallToolResult> => {
return {
content: [
{
type: 'text',
text: `JSON Schema 2020-12 tool called with: ${JSON.stringify(args)}`
}
]
};
}
);
// ===== RESOURCES =====
// Static text resource
mcpServer.registerResource(
'static-text',
'test://static-text',
{
title: 'Static Text Resource',
description: 'A static text resource for testing',
mimeType: 'text/plain'
},
async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri: 'test://static-text',
mimeType: 'text/plain',
text: 'This is the content of the static text resource.'
}
]
};
}
);
// Static binary resource
mcpServer.registerResource(
'static-binary',
'test://static-binary',
{
title: 'Static Binary Resource',
description: 'A static binary resource (image) for testing',
mimeType: 'image/png'
},
async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri: 'test://static-binary',
mimeType: 'image/png',
blob: TEST_IMAGE_BASE64
}
]
};
}
);
// Resource template
mcpServer.registerResource(
'template',
new ResourceTemplate('test://template/{id}/data', { list: undefined }),
{
title: 'Resource Template',
description: 'A resource template with parameter substitution',
mimeType: 'application/json'
},
async (uri, variables): Promise<ReadResourceResult> => {
const id = variables.id;
return {
contents: [
{
uri: uri.toString(),
mimeType: 'application/json',
text: JSON.stringify({
id,
templateTest: true,
data: `Data for ID: ${id}`
})
}
]
};
}
);
// Watched resource
mcpServer.registerResource(
'watched-resource',
'test://watched-resource',
{
title: 'Watched Resource',
description: 'A resource that auto-updates every 3 seconds',
mimeType: 'text/plain'
},
async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri: 'test://watched-resource',
mimeType: 'text/plain',
text: watchedResourceContent
}
]
};
}
);
// Subscribe/Unsubscribe handlers
mcpServer.server.setRequestHandler(SubscribeRequestSchema, async request => {
const uri = request.params.uri;
resourceSubscriptions.add(uri);
sendLog('info', `Subscribed to resource: ${uri}`);
return {};
});
mcpServer.server.setRequestHandler(UnsubscribeRequestSchema, async request => {
const uri = request.params.uri;
resourceSubscriptions.delete(uri);
sendLog('info', `Unsubscribed from resource: ${uri}`);
return {};
});
// ===== PROMPTS =====
// Simple prompt
mcpServer.prompt('test_simple_prompt', 'A simple prompt without arguments', async (): Promise<GetPromptResult> => {
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'This is a simple prompt for testing.'
}
}
]
};
});
// Prompt with arguments
mcpServer.prompt(
'test_prompt_with_arguments',
'A prompt with required arguments',
{
arg1: z.string().describe('First test argument'),
arg2: z.string().describe('Second test argument')
},
async (args: { arg1: string; arg2: string }): Promise<GetPromptResult> => {
return {
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Prompt with arguments: arg1='${args.arg1}', arg2='${args.arg2}'`
}
}
]
};
}
);
// Prompt with embedded resource
mcpServer.prompt(
'test_prompt_with_embedded_resource',
'A prompt that includes an embedded resource',
{
resourceUri: z.string().describe('URI of the resource to embed')
},
async (args: { resourceUri: string }): Promise<GetPromptResult> => {
return {
messages: [
{
role: 'user',
content: {
type: 'resource',
resource: {
uri: args.resourceUri,
mimeType: 'text/plain',
text: 'Embedded resource content for testing.'
}
}
},
{
role: 'user',
content: {
type: 'text',
text: 'Please process the embedded resource above.'
}
}
]
};
}
);
// Prompt with image
mcpServer.prompt('test_prompt_with_image', 'A prompt that includes image content', async (): Promise<GetPromptResult> => {
return {
messages: [
{
role: 'user',
content: {
type: 'image',
data: TEST_IMAGE_BASE64,
mimeType: 'image/png'
}
},
{
role: 'user',
content: { type: 'text', text: 'Please analyze the image above.' }
}
]
};
});
// ===== LOGGING =====
mcpServer.server.setRequestHandler(SetLevelRequestSchema, async request => {
const level = request.params.level;
sendLog('info', `Log level set to: ${level}`);
return {};
});
// ===== COMPLETION =====
mcpServer.server.setRequestHandler(CompleteRequestSchema, async () => {
return {
completion: {
values: [],
total: 0,
hasMore: false
}
};
});
return mcpServer;
}
// ===== EXPRESS APP =====
const app = express();
app.use(express.json());
app.use(localhostHostValidation());
// 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']
})
);
// Handle POST requests - stateful mode
app.post('/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
try {
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
const mcpServer = createMcpServer();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore: createEventStore(),
retryInterval: 5000,
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
app.get('/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
const lastEventId = req.headers['last-event-id'] as string | undefined;
if (lastEventId) {
console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
} else {
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
app.delete('/mcp', 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
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`MCP Conformance Test Server running on http://localhost:${PORT}`);
console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`);
});

View file

@ -0,0 +1,87 @@
import type { OAuthClientProvider } from '../../../../src/client/auth.js';
import type { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from '../../../../src/shared/auth.js';
export class ConformanceOAuthProvider implements OAuthClientProvider {
private _clientInformation?: OAuthClientInformationFull;
private _tokens?: OAuthTokens;
private _codeVerifier?: string;
private _authCode?: string;
private _authCodePromise?: Promise<string>;
constructor(
private readonly _redirectUrl: string | URL,
private readonly _clientMetadata: OAuthClientMetadata,
private readonly _clientMetadataUrl?: string | URL
) {}
get redirectUrl(): string | URL {
return this._redirectUrl;
}
get clientMetadata(): OAuthClientMetadata {
return this._clientMetadata;
}
get clientMetadataUrl(): string | undefined {
return this._clientMetadataUrl?.toString();
}
clientInformation(): OAuthClientInformation | undefined {
return this._clientInformation;
}
saveClientInformation(clientInformation: OAuthClientInformationFull): void {
this._clientInformation = clientInformation;
}
tokens(): OAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
this._tokens = tokens;
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
try {
const response = await fetch(authorizationUrl.toString(), {
redirect: 'manual'
});
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;
} else {
throw new Error('No auth code in redirect URL');
}
} else {
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<string> {
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;
}
}

View file

@ -0,0 +1,27 @@
/**
* Simple logger with configurable log levels.
* Set to 'error' in tests to suppress debug output.
*/
export type LogLevel = 'debug' | 'error';
let currentLogLevel: LogLevel = 'debug';
export function setLogLevel(level: LogLevel): void {
currentLogLevel = level;
}
export function getLogLevel(): LogLevel {
return currentLogLevel;
}
export const logger = {
debug: (...args: unknown[]): void => {
if (currentLogLevel === 'debug') {
console.log(...args);
}
},
error: (...args: unknown[]): void => {
console.error(...args);
}
};

View file

@ -0,0 +1,81 @@
import type { FetchLike } from '../../../../src/shared/transport.js';
import type { Middleware } from '../../../../src/client/middleware.js';
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '../../../../src/client/auth.js';
import { ConformanceOAuthProvider } from './conformanceOAuthProvider.js';
export const handle401 = async (
response: Response,
provider: ConformanceOAuthProvider,
next: FetchLike,
serverUrl: string | URL
): Promise<void> => {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
let result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
fetchFn: next
});
if (result === 'REDIRECT') {
const authorizationCode = await provider.getAuthCode();
result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
authorizationCode,
fetchFn: next
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError(`Authentication failed with result: ${result}`);
}
}
};
export const withOAuthRetry = (
clientName: string,
baseUrl?: string | URL,
handle401Fn: typeof handle401 = handle401,
clientMetadataUrl?: string
): Middleware => {
const provider = new ConformanceOAuthProvider(
'http://localhost:3000/callback',
{
client_name: clientName,
redirect_uris: ['http://localhost:3000/callback']
},
clientMetadataUrl
);
return (next: FetchLike) => {
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
const makeRequest = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
const tokens = await provider.tokens();
if (tokens) {
headers.set('Authorization', `Bearer ${tokens.access_token}`);
}
return await next(input, { ...init, headers });
};
let response = await makeRequest();
if (response.status === 401 || response.status === 403) {
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
await handle401Fn(response, provider, next, serverUrl);
response = await makeRequest();
}
if (response.status === 401 || response.status === 403) {
const url = typeof input === 'string' ? input : input.toString();
throw new UnauthorizedError(`Authentication failed for ${url}`);
}
return response;
};
};
};

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist"
}
}