From a173c1d84d15d3b9b930618b6fadb2a78aac5f40 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 12 Jan 2026 18:35:23 +0000 Subject: [PATCH 1/5] Add server conformance testing - Move conformance server to src/conformance/everything-server.ts - Add scripts/run-server-conformance.sh to start server and run tests - Add server conformance scripts to package.json - Update GitHub Actions workflow with both client and server jobs - Add src/conformance/README.md with usage instructions - Add express, cors, and @modelcontextprotocol/server to root devDependencies --- .github/workflows/conformance.yml | 24 +- .gitignore | 3 + package.json | 8 +- pnpm-lock.yaml | 9 + scripts/run-server-conformance.sh | 46 ++ src/conformance/README.md | 64 ++ src/conformance/everything-server.ts | 1063 ++++++++++++++++++++++++++ 7 files changed, 1215 insertions(+), 2 deletions(-) create mode 100755 scripts/run-server-conformance.sh create mode 100644 src/conformance/README.md create mode 100644 src/conformance/everything-server.ts diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 8caa40e50..c32de5b9e 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -6,13 +6,17 @@ on: pull_request: workflow_dispatch: +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: client-conformance: runs-on: ubuntu-latest - continue-on-error: true # Non-blocking initially + continue-on-error: true steps: - uses: actions/checkout@v4 - name: Install pnpm @@ -27,3 +31,21 @@ jobs: - run: pnpm install - run: pnpm run build:all - run: pnpm run test:conformance:client:all + + server-conformance: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install + - run: pnpm run build:all + - run: pnpm run test:conformance:server diff --git a/.gitignore b/.gitignore index a1b83bc4f..324c3d2b7 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ dist/ # IDE .idea/ + +# Conformance test results +results/ diff --git a/package.json b/package.json index bb787f46a..423cff9ba 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,20 @@ "test:all": "pnpm -r test", "test:conformance:client": "conformance client --command 'npx tsx src/conformance/everything-client.ts'", "test:conformance:client:all": "conformance client --command 'npx tsx src/conformance/everything-client.ts' --suite all", - "test:conformance:client:run": "npx tsx src/conformance/everything-client.ts" + "test:conformance:client:run": "npx tsx src/conformance/everything-client.ts", + "test:conformance:server": "scripts/run-server-conformance.sh", + "test:conformance:server:all": "scripts/run-server-conformance.sh all", + "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { "@cfworker/json-schema": "catalog:runtimeShared", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", "@eslint/js": "catalog:devTools", "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/conformance": "0.1.9", "@types/content-type": "catalog:devTools", "@types/cors": "catalog:devTools", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07aee5fda..1e4101b5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: '@modelcontextprotocol/conformance': specifier: 0.1.9 version: 0.1.9(@cfworker/json-schema@4.1.1)(hono@4.11.1) + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:packages/server '@types/content-type': specifier: catalog:devTools version: 1.1.9 @@ -185,6 +188,9 @@ importers: '@typescript/native-preview': specifier: catalog:devTools version: 7.0.0-dev.20251218.3 + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.5 eslint: specifier: catalog:devTools version: 9.39.1 @@ -194,6 +200,9 @@ importers: eslint-plugin-n: specifier: catalog:devTools version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) + express: + specifier: catalog:runtimeServerOnly + version: 5.1.0 prettier: specifier: catalog:devTools version: 3.6.2 diff --git a/scripts/run-server-conformance.sh b/scripts/run-server-conformance.sh new file mode 100755 index 000000000..a3a7743a9 --- /dev/null +++ b/scripts/run-server-conformance.sh @@ -0,0 +1,46 @@ +#!/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 the 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 src/conformance/everything-server.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 - use suite from argument or default to "active" +SUITE="${1:-active}" +npx @modelcontextprotocol/conformance server --url "${SERVER_URL}" --suite "${SUITE}" + +echo "Conformance tests completed." diff --git a/src/conformance/README.md b/src/conformance/README.md new file mode 100644 index 000000000..281283409 --- /dev/null +++ b/src/conformance/README.md @@ -0,0 +1,64 @@ +# Conformance Tests + +This directory contains conformance test implementations for the TypeScript MCP SDK. + +## Client Conformance Tests + +Tests the SDK's client implementation against a conformance test server. + +```bash +# Run all client tests +pnpm run test:conformance:client:all + +# Run specific suite +pnpm run test:conformance:client -- --suite auth + +# Run single scenario +pnpm run test:conformance:client -- --scenario auth/basic-cimd +``` + +## Server Conformance Tests + +Tests the SDK's server implementation by running a conformance server. + +```bash +# Run all active server tests +pnpm run test:conformance:server + +# Run all server tests (including pending) +pnpm run test:conformance:server:all +``` + +## Local Development + +### Running Tests Against Local Conformance Repo + +Link the local conformance package: +```bash +cd ~/code/mcp/typescript-sdk +pnpm link ~/code/mcp/conformance +``` + +Then run tests as above. + +### Debugging Server Tests + +Start the server manually: +```bash +npx tsx src/conformance/everything-server.ts +``` + +In another terminal, run specific tests: +```bash +npx @modelcontextprotocol/conformance server \ + --url http://localhost:3000/mcp \ + --scenario server-initialize +``` + +## 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 + +Scripts are in `scripts/` at the repo root. diff --git a/src/conformance/everything-server.ts b/src/conformance/everything-server.ts new file mode 100644 index 000000000..966c21e82 --- /dev/null +++ b/src/conformance/everything-server.ts @@ -0,0 +1,1063 @@ +#!/usr/bin/env node + +/** + * MCP Conformance Test Server + * + * Server implementing all MCP features for conformance testing. + * This server is designed to pass all conformance test scenarios. + */ + +import { randomUUID } from 'node:crypto'; + +import type { + CallToolResult, + GetPromptResult, + ReadResourceResult, + Tool, + EventId, + EventStore, + StreamId +} from '@modelcontextprotocol/server'; +import { + CompleteRequestSchema, + ElicitResultSchema, + isInitializeRequest, + SetLevelRequestSchema, + McpServer, + ResourceTemplate, + StreamableHTTPServerTransport, + SubscribeRequestSchema, + UnsubscribeRequestSchema +} from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import cors from 'cors'; +import express from 'express'; +import * as z from 'zod/v4'; + +// Server state +const resourceSubscriptions = new Set(); +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(); + +function createEventStore(): EventStore { + return { + async storeEvent(streamId: StreamId, message: unknown): Promise { + 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 } + ): Promise { + 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='; + +// SEP-1613: Raw JSON Schema 2020-12 definition for conformance testing +// This schema includes $schema, $defs, and additionalProperties to test +// that SDKs correctly preserve these fields +const JSON_SCHEMA_2020_12_INPUT_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object' as const, + $defs: { + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' } + } + } + }, + properties: { + name: { type: 'string' }, + address: { $ref: '#/$defs/address' } + }, + additionalProperties: false +}; + +// Function to create a new MCP server instance (one per session) +function createMcpServer(sessionId?: string) { + 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.registerTool( + 'test_simple_text', + { + description: 'Tests simple text content response' + }, + async (): Promise => { + return { + content: [{ type: 'text', text: 'This is a simple text response for testing.' }] + }; + } + ); + + // Image content tool + mcpServer.registerTool( + 'test_image_content', + { + description: 'Tests image content response' + }, + async (): Promise => { + return { + content: [{ type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }] + }; + } + ); + + // Audio content tool + mcpServer.registerTool( + 'test_audio_content', + { + description: 'Tests audio content response' + }, + async (): Promise => { + return { + content: [{ type: 'audio', data: TEST_AUDIO_BASE64, mimeType: 'audio/wav' }] + }; + } + ); + + // Embedded resource tool + mcpServer.registerTool( + 'test_embedded_resource', + { + description: 'Tests embedded resource content response' + }, + async (): Promise => { + return { + content: [ + { + type: 'resource', + resource: { + uri: 'test://embedded-resource', + mimeType: 'text/plain', + text: 'This is an embedded resource content.' + } + } + ] + }; + } + ); + + // Multiple content types tool + mcpServer.registerTool( + 'test_multiple_content_types', + { + description: 'Tests response with multiple content types (text, image, resource)' + }, + async (): Promise => { + 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.registerTool( + 'test_tool_with_logging', + { + description: 'Tests tool that emits log messages during execution', + inputSchema: {} + }, + async (_args, extra): Promise => { + 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.registerTool( + 'test_tool_with_progress', + { + description: 'Tests tool that reports progress notifications', + inputSchema: {} + }, + async (_args, extra): Promise => { + 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.registerTool( + 'test_error_handling', + { + description: 'Tests error response handling' + }, + async (): Promise => { + 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.registerTool( + 'test_reconnection', + { + description: + 'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.', + inputSchema: {} + }, + async (_args, extra): Promise => { + 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.registerTool( + 'test_sampling', + { + description: 'Tests server-initiated sampling (LLM completion request)', + inputSchema: { + prompt: z.string().describe('The prompt to send to the LLM') + } + }, + async (args: { prompt: string }, extra): Promise => { + 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 + } + }, + z.object({ method: z.literal('sampling/createMessage') }).passthrough() + )) 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.registerTool( + 'test_elicitation', + { + description: 'Tests server-initiated elicitation (user input request)', + inputSchema: { + message: z.string().describe('The message to show the user') + } + }, + async (args: { message: string }, extra): Promise => { + 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.registerTool( + 'test_elicitation_sep1034_defaults', + { + description: 'Tests elicitation with default values per SEP-1034', + inputSchema: {} + }, + async (_args, extra): Promise => { + try { + // Request user input with default values for all primitive types + 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.registerTool( + 'test_elicitation_sep1330_enums', + { + description: 'Tests elicitation with enum schema improvements per SEP-1330', + inputSchema: {} + }, + async (_args, extra): Promise => { + try { + // Request user input with all 5 enum schema variants + const result = await extra.sendRequest( + { + method: 'elicitation/create', + params: { + message: 'Please select options from the enum fields', + requestedSchema: { + type: 'object', + properties: { + // Untitled single-select enum (basic) + untitledSingle: { + type: 'string', + description: 'Select one option', + enum: ['option1', 'option2', 'option3'] + }, + // Titled single-select enum (using oneOf with const/title) + 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' } + ] + }, + // Legacy titled enum (using enumNames - deprecated) + legacyEnum: { + type: 'string', + description: 'Select one option (legacy)', + enum: ['opt1', 'opt2', 'opt3'], + enumNames: ['Option One', 'Option Two', 'Option Three'] + }, + // Untitled multi-select enum + untitledMulti: { + type: 'array', + description: 'Select multiple options', + minItems: 1, + maxItems: 3, + items: { + type: 'string', + enum: ['option1', 'option2', 'option3'] + } + }, + // Titled multi-select enum (using anyOf with const/title) + 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 + mcpServer.registerTool( + 'json_schema_2020_12_tool', + { + description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', + inputSchema: { + name: z.string().optional(), + address: z + .object({ + street: z.string().optional(), + city: z.string().optional() + }) + .optional() + } + }, + async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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.registerPrompt( + 'test_simple_prompt', + { + title: 'Simple Test Prompt', + description: 'A simple prompt without arguments' + }, + async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a simple prompt for testing.' + } + } + ] + }; + } + ); + + // Prompt with arguments + mcpServer.registerPrompt( + 'test_prompt_with_arguments', + { + title: 'Prompt With Arguments', + description: 'A prompt with required arguments', + argsSchema: { + arg1: z.string().describe('First test argument'), + arg2: z.string().describe('Second test argument') + } + }, + async (args: { arg1: string; arg2: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Prompt with arguments: arg1='${args.arg1}', arg2='${args.arg2}'` + } + } + ] + }; + } + ); + + // Prompt with embedded resource + mcpServer.registerPrompt( + 'test_prompt_with_embedded_resource', + { + title: 'Prompt With Embedded Resource', + description: 'A prompt that includes an embedded resource', + argsSchema: { + resourceUri: z.string().describe('URI of the resource to embed') + } + }, + async (args: { resourceUri: string }): Promise => { + 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.registerPrompt( + 'test_prompt_with_image', + { + title: 'Prompt With Image', + description: 'A prompt that includes image content' + }, + async (): Promise => { + 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 () => { + // Basic completion support - returns empty array for conformance + // Real implementations would provide contextual suggestions + return { + completion: { + values: [], + total: 0, + hasMore: false + } + }; + }); + + return mcpServer; +} + +// ===== 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'] + }) +); + +// 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]) { + // 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(), + eventStore: createEventStore(), + retryInterval: 5000, // 5 second retry interval for SEP-1699 + 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: -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 +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`); +}); From 025a7e49a33a5ec22db6569e18ce1118460cc707 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 12 Jan 2026 19:44:57 +0000 Subject: [PATCH 2/5] Address code review feedback - Remove unused Tool type import - Remove unused JSON_SCHEMA_2020_12_INPUT_SCHEMA constant (SEP-1613 test is pending - SDK validation supports the fields via PR #1135, but tool registration doesn't yet support generating raw JSON Schema) - Remove emoji from console.log - Alphabetize devDependencies in package.json --- package.json | 6 +++--- src/conformance/everything-server.ts | 25 +------------------------ 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 423cff9ba..9c7ec221b 100644 --- a/package.json +++ b/package.json @@ -39,14 +39,12 @@ }, "devDependencies": { "@cfworker/json-schema": "catalog:runtimeShared", - "cors": "catalog:runtimeServerOnly", - "express": "catalog:runtimeServerOnly", "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.29.8", "@eslint/js": "catalog:devTools", "@modelcontextprotocol/client": "workspace:^", - "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/conformance": "0.1.9", + "@modelcontextprotocol/server": "workspace:^", "@types/content-type": "catalog:devTools", "@types/cors": "catalog:devTools", "@types/cross-spawn": "catalog:devTools", @@ -57,9 +55,11 @@ "@types/supertest": "catalog:devTools", "@types/ws": "catalog:devTools", "@typescript/native-preview": "catalog:devTools", + "cors": "catalog:runtimeServerOnly", "eslint": "catalog:devTools", "eslint-config-prettier": "catalog:devTools", "eslint-plugin-n": "catalog:devTools", + "express": "catalog:runtimeServerOnly", "prettier": "catalog:devTools", "supertest": "catalog:devTools", "tsdown": "catalog:devTools", diff --git a/src/conformance/everything-server.ts b/src/conformance/everything-server.ts index 966c21e82..a4a40b403 100644 --- a/src/conformance/everything-server.ts +++ b/src/conformance/everything-server.ts @@ -13,7 +13,6 @@ import type { CallToolResult, GetPromptResult, ReadResourceResult, - Tool, EventId, EventStore, StreamId @@ -81,28 +80,6 @@ const TEST_IMAGE_BASE64 = // Sample base64 encoded minimal WAV file for testing const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; -// SEP-1613: Raw JSON Schema 2020-12 definition for conformance testing -// This schema includes $schema, $defs, and additionalProperties to test -// that SDKs correctly preserve these fields -const JSON_SCHEMA_2020_12_INPUT_SCHEMA = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - type: 'object' as const, - $defs: { - address: { - type: 'object', - properties: { - street: { type: 'string' }, - city: { type: 'string' } - } - } - }, - properties: { - name: { type: 'string' }, - address: { $ref: '#/$defs/address' } - }, - additionalProperties: false -}; - // Function to create a new MCP server instance (one per session) function createMcpServer(sessionId?: string) { const mcpServer = new McpServer( @@ -283,7 +260,7 @@ function createMcpServer(sessionId?: string) { }, async (_args, extra): Promise => { const progressToken = extra._meta?.progressToken ?? 0; - console.log('📊 Progress token:', progressToken); + console.log('Progress token:', progressToken); await extra.sendNotification({ method: 'notifications/progress', params: { From 7a74bc0fe274eeff41379d8ec23457e579670715 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 Jan 2026 09:14:13 +0000 Subject: [PATCH 3/5] Make server conformance script args more flexible Pass through all arguments to conformance CLI instead of just suite name. This allows passing --scenario, --suite, or any other flags. --- package.json | 2 +- scripts/run-server-conformance.sh | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 9c7ec221b..13f91310a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "test:conformance:client:all": "conformance client --command 'npx tsx src/conformance/everything-client.ts' --suite all", "test:conformance:client:run": "npx tsx src/conformance/everything-client.ts", "test:conformance:server": "scripts/run-server-conformance.sh", - "test:conformance:server:all": "scripts/run-server-conformance.sh all", + "test:conformance:server:all": "scripts/run-server-conformance.sh --suite all", "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { diff --git a/scripts/run-server-conformance.sh b/scripts/run-server-conformance.sh index a3a7743a9..4de4a1ae4 100755 --- a/scripts/run-server-conformance.sh +++ b/scripts/run-server-conformance.sh @@ -39,8 +39,7 @@ done echo "Server is ready. Running conformance tests..." -# Run conformance tests - use suite from argument or default to "active" -SUITE="${1:-active}" -npx @modelcontextprotocol/conformance server --url "${SERVER_URL}" --suite "${SUITE}" +# Run conformance tests - pass through all arguments +npx @modelcontextprotocol/conformance server --url "${SERVER_URL}" "$@" echo "Conformance tests completed." From 885630345c43df6072e931efd6bb312247ac2a29 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 Jan 2026 09:23:33 +0000 Subject: [PATCH 4/5] Add test:conformance:server:run script Runs the conformance server standalone for debugging. --- package.json | 1 + src/conformance/README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 13f91310a..90f1c2e7c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test:conformance:client:run": "npx tsx src/conformance/everything-client.ts", "test:conformance:server": "scripts/run-server-conformance.sh", "test:conformance:server:all": "scripts/run-server-conformance.sh --suite all", + "test:conformance:server:run": "npx tsx src/conformance/everything-server.ts", "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { diff --git a/src/conformance/README.md b/src/conformance/README.md index 281283409..e3978f421 100644 --- a/src/conformance/README.md +++ b/src/conformance/README.md @@ -45,7 +45,7 @@ Then run tests as above. Start the server manually: ```bash -npx tsx src/conformance/everything-server.ts +pnpm run test:conformance:server:run ``` In another terminal, run specific tests: From 1e63e17f4ddffdc0c24d28259ab813ebfe8136d6 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 Jan 2026 09:39:01 +0000 Subject: [PATCH 5/5] fix: override tools/list handler to emit raw JSON Schema 2020-12 The Zod-to-JSON-Schema conversion strips $schema, $defs, and additionalProperties fields. Override the ListToolsRequestSchema handler to inject the raw JSON Schema for the SEP-1613 test tool. This enables all server conformance tests to pass, including the json-schema-2020-12 scenario that tests SEP-1613 compliance. --- src/conformance/everything-server.ts | 43 +++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/conformance/everything-server.ts b/src/conformance/everything-server.ts index a4a40b403..d93976130 100644 --- a/src/conformance/everything-server.ts +++ b/src/conformance/everything-server.ts @@ -21,6 +21,7 @@ import { CompleteRequestSchema, ElicitResultSchema, isInitializeRequest, + ListToolsRequestSchema, SetLevelRequestSchema, McpServer, ResourceTemplate, @@ -80,6 +81,29 @@ const TEST_IMAGE_BASE64 = // Sample base64 encoded minimal WAV file for testing const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; +// SEP-1613: Raw JSON Schema 2020-12 definition for conformance testing +// This schema includes $schema, $defs, and additionalProperties to test +// that SDKs correctly preserve these fields when listing tools +const JSON_SCHEMA_2020_12_TOOL_NAME = 'json_schema_2020_12_tool'; +const JSON_SCHEMA_2020_12_INPUT_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object' as const, + $defs: { + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' } + } + } + }, + properties: { + name: { type: 'string' }, + address: { $ref: '#/$defs/address' } + }, + additionalProperties: false +}; + // Function to create a new MCP server instance (one per session) function createMcpServer(sessionId?: string) { const mcpServer = new McpServer( @@ -627,8 +651,9 @@ function createMcpServer(sessionId?: string) { ); // SEP-1613: JSON Schema 2020-12 conformance test tool + // Register with Zod for call handling, but we'll override the inputSchema in ListTools mcpServer.registerTool( - 'json_schema_2020_12_tool', + JSON_SCHEMA_2020_12_TOOL_NAME, { description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', inputSchema: { @@ -653,6 +678,22 @@ function createMcpServer(sessionId?: string) { } ); + // Override ListToolsRequestSchema to inject raw JSON Schema 2020-12 for SEP-1613 test + // This is necessary because McpServer's registerTool converts Zod to JSON Schema, + // which strips $schema, $defs, and additionalProperties fields + const originalListToolsHandler = mcpServer.server['_requestHandlers'].get('tools/list'); + if (originalListToolsHandler) { + mcpServer.server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => { + const result = (await originalListToolsHandler(request, extra)) as { tools: Array<{ name: string; inputSchema: unknown }> }; + // Replace the inputSchema for our SEP-1613 test tool + const tool = result.tools.find(t => t.name === JSON_SCHEMA_2020_12_TOOL_NAME); + if (tool) { + tool.inputSchema = JSON_SCHEMA_2020_12_INPUT_SCHEMA; + } + return result; + }); + } + // ===== RESOURCES ===== // Static text resource