diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 111f97e5..6bef0336 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -29,6 +29,11 @@ import { JWT_BEARER_GRANT_TYPE } from '../../../src/scenarios/client/auth/helper import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; import { DRAFT_PROTOCOL_VERSION } from '../../../src/types.js'; +import { + classifyHttpFallbackResponse, + MODERN_PROBE_ID, + modernProbeRequestInit +} from '../../../src/version-compat.js'; import { STATELESS_SPEC_VERSIONS } from '../../../src/connection/select.js'; import { auth, @@ -155,6 +160,31 @@ async function statelessRequest( // Basic scenarios (initialize, tools_call) // ============================================================================ +async function runLegacyBasicClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + try { + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const list = await client.listTools(); + logger.debug('Successfully listed tools'); + + const tool = list.tools[0]; + if (tool) { + await client.callTool({ name: tool.name, arguments: { a: 2, b: 3 } }); + logger.debug('Successfully called tool'); + } + } finally { + await transport.close(); + logger.debug('Connection closed successfully'); + } +} + async function runBasicClient(serverUrl: string): Promise { if (USE_STATELESS_LIFECYCLE) { logger.debug('Stateless lifecycle: calling tools/list + tools/call'); @@ -171,30 +201,51 @@ async function runBasicClient(serverUrl: string): Promise { return; } - const client = new Client( - { name: 'test-client', version: '1.0.0' }, - { capabilities: {} } - ); - - const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await runLegacyBasicClient(serverUrl); +} - await client.connect(transport); - logger.debug('Successfully connected to MCP server'); +const legacyOrigins = new Set(); - const list = await client.listTools(); - logger.debug('Successfully listed tools'); +async function runVersionBackcompatClient(serverUrl: string): Promise { + const origin = new URL(serverUrl).origin; + const usedCachedLegacyDecision = legacyOrigins.has(origin); + if (!usedCachedLegacyDecision) { + const response = await fetch(serverUrl, modernProbeRequestInit()); + const classification = await classifyHttpFallbackResponse( + response, + MODERN_PROBE_ID + ); - const tool = list.tools[0]; - if (tool) { - await client.callTool({ name: tool.name, arguments: { a: 2, b: 3 } }); - logger.debug('Successfully called tool'); + if (classification.kind === 'modern') { + throw new Error( + `Modern probe returned recognized modern error code ${classification.errorCode}` + ); + } + if (classification.kind === 'unavailable') { + throw new Error(`Modern probe failed: ${classification.error}`); + } + if (classification.kind !== 'legacy-fallback') { + throw new Error( + `Expected legacy endpoint to reject the modern probe with HTTP 400, got ${classification.status}` + ); + } + legacyOrigins.add(origin); } - await transport.close(); - logger.debug('Connection closed successfully'); + try { + await runLegacyBasicClient(serverUrl); + } catch (error) { + legacyOrigins.delete(origin); + if (usedCachedLegacyDecision) { + await runVersionBackcompatClient(serverUrl); + return; + } + throw error; + } } registerScenarios(['initialize', 'tools_call', 'tools-call'], runBasicClient); +registerScenario('version-backcompat', runVersionBackcompatClient); // SEP-2106: json-schema-ref-no-deref advertises a tool whose inputSchema // contains a network-URI $ref. A conformant client lists tools normally and diff --git a/examples/servers/typescript/legacy-version-server.ts b/examples/servers/typescript/legacy-version-server.ts new file mode 100644 index 00000000..913e8a68 --- /dev/null +++ b/examples/servers/typescript/legacy-version-server.ts @@ -0,0 +1,104 @@ +import http from 'http'; +import { DRAFT_PROTOCOL_VERSION } from '../../../src/types.js'; +import { UNSUPPORTED_PROTOCOL_VERSION } from '../../../src/spec-types/draft.js'; +import { + isDraftModernProbe, + LEGACY_PROTOCOL_VERSION, + parseJsonRecord, + readLimitedRequestBody +} from '../../../src/version-compat.js'; + +const port = Number.parseInt(process.env.PORT ?? '3010', 10); +const emitModernError = process.env.EMIT_MODERN_ERROR === 'true'; + +const server = http.createServer((request, response) => { + void handleRequest(request, response); +}); +server.requestTimeout = 5_000; +server.headersTimeout = 5_000; + +async function handleRequest( + request: http.IncomingMessage, + response: http.ServerResponse +): Promise { + let rawBody: string; + try { + rawBody = await readLimitedRequestBody(request); + } catch (error) { + response.writeHead(error instanceof RangeError ? 413 : 400).end(); + return; + } + + const body = parseJsonRecord(rawBody); + if (!body) { + response.writeHead(400).end(); + return; + } + + if (isDraftModernProbe(request, body)) { + response.writeHead(400, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id ?? null, + error: emitModernError + ? { + code: UNSUPPORTED_PROTOCOL_VERSION, + message: 'Unsupported protocol version', + data: { + supported: [LEGACY_PROTOCOL_VERSION], + requested: DRAFT_PROTOCOL_VERSION + } + } + : { code: -32600, message: 'Invalid Request' } + }) + ); + return; + } + + if (body.method === 'initialize') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + result: { + protocolVersion: LEGACY_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'legacy-version-server', version: '1.0.0' } + } + }) + ); + return; + } + + if (body.method === 'notifications/initialized') { + response.writeHead(202).end(); + return; + } + + if (body.method === 'tools/list') { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + result: { tools: [] } + }) + ); + return; + } + + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: {} })); +} + +server.listen(port, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to determine legacy server port'); + } + console.log( + `Legacy version server running on http://127.0.0.1:${address.port}/mcp` + ); +}); diff --git a/src/scenarios/client/version-backcompat-modern-error.test.ts b/src/scenarios/client/version-backcompat-modern-error.test.ts new file mode 100644 index 00000000..e17bbfb1 --- /dev/null +++ b/src/scenarios/client/version-backcompat-modern-error.test.ts @@ -0,0 +1,219 @@ +import http from 'http'; +import { getHandler } from '../../../examples/clients/typescript/everything-client'; +import { + MODERN_PROBE_ID, + RECOGNIZED_MODERN_ERROR_CODES +} from '../../version-compat'; + +const SCENARIO = 'version-backcompat'; + +interface TestServer { + url: string; + counts: { modernProbes: number; initializes: number }; + close(): Promise; +} + +interface ModernResponse { + status: number; + body?: unknown; + contentType?: string; + rejectLegacyRequests?: boolean; +} + +async function startTestServer( + modernResponse: ModernResponse +): Promise { + const counts = { modernProbes: 0, initializes: 0 }; + const server = http.createServer((request, response) => { + let rawBody = ''; + request.on('data', (chunk) => (rawBody += chunk.toString())); + request.on('end', () => { + if (rawBody.length === 0) { + response.writeHead(200).end(); + return; + } + const body = JSON.parse(rawBody); + const isModernProbe = + body.method === 'tools/list' && + request.headers['mcp-protocol-version'] === '2026-07-28'; + if (isModernProbe) { + counts.modernProbes += 1; + const headers = modernResponse.contentType + ? { 'Content-Type': modernResponse.contentType } + : undefined; + response.writeHead(modernResponse.status, headers); + let responseBody: string | undefined; + if (typeof modernResponse.body === 'string') { + responseBody = modernResponse.body; + } else if (modernResponse.body !== undefined) { + responseBody = JSON.stringify(modernResponse.body); + } + response.end(responseBody); + return; + } + if (body.method === 'initialize') { + counts.initializes += 1; + if (modernResponse.rejectLegacyRequests) { + response.writeHead(400, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + error: { code: -32022, message: 'Modern-only endpoint' } + }) + ); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + result: { + protocolVersion: '2025-11-25', + capabilities: { tools: {} }, + serverInfo: { name: 'legacy-test-server', version: '1.0.0' } + } + }) + ); + return; + } + if (body.method === 'notifications/initialized') { + response.writeHead(202).end(); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + result: body.method === 'tools/list' ? { tools: [] } : {} + }) + ); + }); + }); + + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to determine compatibility fixture port'); + } + + return { + url: `http://127.0.0.1:${address.port}/mcp`, + counts, + async close(): Promise { + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + } + }; +} + +function modernError( + code: number, + id: string = MODERN_PROBE_ID +): ModernResponse { + return { + status: 400, + contentType: 'application/json', + body: { + jsonrpc: '2.0', + id, + error: { code, message: 'Modern protocol error' } + } + }; +} + +describe('version backward-compatibility HTTP classification', () => { + test.each([...RECOGNIZED_MODERN_ERROR_CODES])( + 'recognized modern error %i does not trigger initialize fallback', + async (code) => { + const fixture = await startTestServer(modernError(code)); + try { + const handler = getHandler(SCENARIO)!; + await expect(handler(fixture.url)).rejects.toThrow( + `recognized modern error code ${code}` + ); + expect(fixture.counts).toEqual({ modernProbes: 1, initializes: 0 }); + } finally { + await fixture.close(); + } + } + ); + + test.each([401, 403, 404, 405, 500])( + 'HTTP %i does not trigger initialize fallback', + async (status) => { + const fixture = await startTestServer({ status }); + try { + const handler = getHandler(SCENARIO)!; + await expect(handler(fixture.url)).rejects.toThrow( + `Expected legacy endpoint to reject the modern probe with HTTP 400, got ${status}` + ); + expect(fixture.counts).toEqual({ modernProbes: 1, initializes: 0 }); + } finally { + await fixture.close(); + } + } + ); + + test('empty and non-JSON HTTP 400 responses trigger legacy fallback', async () => { + for (const body of [undefined, 'legacy endpoint']) { + const fixture = await startTestServer({ status: 400, body }); + try { + await expect( + getHandler(SCENARIO)!(fixture.url) + ).resolves.toBeUndefined(); + expect(fixture.counts).toEqual({ modernProbes: 1, initializes: 1 }); + } finally { + await fixture.close(); + } + } + }); + + test('a recognized code with the wrong response id is treated as legacy', async () => { + const fixture = await startTestServer( + modernError(-32022, 'different-request') + ); + try { + await expect(getHandler(SCENARIO)!(fixture.url)).resolves.toBeUndefined(); + expect(fixture.counts).toEqual({ modernProbes: 1, initializes: 1 }); + } finally { + await fixture.close(); + } + }); + + test('caches the legacy-era decision for subsequent calls to the same origin', async () => { + const fixture = await startTestServer({ status: 400 }); + try { + const handler = getHandler(SCENARIO)!; + await handler(fixture.url); + await handler(fixture.url); + expect(fixture.counts).toEqual({ modernProbes: 1, initializes: 2 }); + } finally { + await fixture.close(); + } + }); + + test('re-probes when a cached legacy-era decision later fails', async () => { + const response: ModernResponse = { status: 400 }; + const fixture = await startTestServer(response); + try { + const handler = getHandler(SCENARIO)!; + await handler(fixture.url); + + Object.assign(response, modernError(-32022), { + rejectLegacyRequests: true + }); + await expect(handler(fixture.url)).rejects.toThrow( + 'recognized modern error code -32022' + ); + expect(fixture.counts).toEqual({ modernProbes: 2, initializes: 2 }); + } finally { + await fixture.close(); + } + }); +}); diff --git a/src/scenarios/client/version-backcompat.test.ts b/src/scenarios/client/version-backcompat.test.ts new file mode 100644 index 00000000..9423ff0a --- /dev/null +++ b/src/scenarios/client/version-backcompat.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'vitest'; +import { getHandler } from '../../../examples/clients/typescript/everything-client'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; +import { + MAX_COMPAT_MESSAGE_BYTES, + modernProbeRequestInit +} from '../../version-compat'; +import type { ScenarioContext } from '../../mock-server'; +import { VersionBackcompatScenario } from './version-backcompat'; +import { + InlineClientRunner, + runClientAgainstScenario +} from './auth/test_helpers/testClient'; + +const SCENARIO = 'version-backcompat'; + +async function modernOnlyClient(serverUrl: string): Promise { + await fetch(serverUrl, modernProbeRequestInit(1)); +} + +async function wrongVersionFallbackClient(serverUrl: string): Promise { + await fetch(serverUrl, modernProbeRequestInit(1)); + await fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'initialize', + params: { + protocolVersion: DRAFT_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'broken-client', version: '1.0.0' } + } + }) + }); + await fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized' + }) + }); + await fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 3, + method: 'tools/list', + params: {} + }) + }); +} + +describe('version backward-compatibility client scenario', () => { + test('the reference dual-era client falls back to initialize', async () => { + const handler = getHandler(SCENARIO); + expect(handler).toBeDefined(); + + const checks = await runClientAgainstScenario( + new InlineClientRunner(handler!), + SCENARIO, + { specVersion: DRAFT_PROTOCOL_VERSION } + ); + + expect(checks.map(({ id, status }) => ({ id, status }))).toEqual([ + { + id: 'version-backcompat-client-modern-probe', + status: 'SUCCESS' + }, + { + id: 'version-backcompat-client-legacy-initialize', + status: 'SUCCESS' + }, + { + id: 'version-backcompat-client-legacy-request', + status: 'SUCCESS' + } + ]); + }); + + test('a modern-only client fails the fallback checks', async () => { + await runClientAgainstScenario( + new InlineClientRunner(modernOnlyClient), + SCENARIO, + { + specVersion: DRAFT_PROTOCOL_VERSION, + expectedSuccessSlugs: ['version-backcompat-client-modern-probe'], + expectedFailureSlugs: [ + 'version-backcompat-client-legacy-initialize', + 'version-backcompat-client-legacy-request' + ] + } + ); + }); + + test('a client that initializes with the modern version fails legacy checks', async () => { + await runClientAgainstScenario( + new InlineClientRunner(wrongVersionFallbackClient), + SCENARIO, + { + specVersion: DRAFT_PROTOCOL_VERSION, + expectedSuccessSlugs: ['version-backcompat-client-modern-probe'], + expectedFailureSlugs: [ + 'version-backcompat-client-legacy-initialize', + 'version-backcompat-client-legacy-request' + ] + } + ); + }); + test('rejects scalar JSON and oversized bodies without crashing the scenario', async () => { + const scenario = new VersionBackcompatScenario(); + const { serverUrl } = await scenario.start({} as ScenarioContext); + try { + const scalar = await fetch(serverUrl, { method: 'POST', body: 'null' }); + expect(scalar.status).toBe(400); + + const oversized = await fetch(serverUrl, { + method: 'POST', + body: 'x'.repeat(MAX_COMPAT_MESSAGE_BYTES + 1) + }); + expect(oversized.status).toBe(413); + } finally { + await scenario.stop(); + } + }); +}); diff --git a/src/scenarios/client/version-backcompat.ts b/src/scenarios/client/version-backcompat.ts new file mode 100644 index 00000000..46555a50 --- /dev/null +++ b/src/scenarios/client/version-backcompat.ts @@ -0,0 +1,222 @@ +import http from 'http'; +import { + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + Scenario, + ScenarioUrls +} from '../../types'; +import type { ScenarioContext } from '../../mock-server'; +import { + isDraftModernProbe, + isRecord, + LEGACY_PROTOCOL_VERSION, + parseJsonRecord, + readLimitedRequestBody +} from '../../version-compat'; +import { HEADER_MISMATCH } from '../../spec-types/draft'; + +const SPEC_REFERENCES = [ + { + id: 'MCP-Version-Compatibility', + url: 'https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility' + } +]; + +const CHECKS = [ + { + id: 'version-backcompat-client-modern-probe', + name: 'ClientModernProbe', + description: + 'Dual-era client sends a valid modern request before selecting legacy behavior' + }, + { + id: 'version-backcompat-client-legacy-initialize', + name: 'ClientLegacyInitializeFallback', + description: + 'Dual-era client falls back to a legacy initialize after an unrecognized HTTP 400 response' + }, + { + id: 'version-backcompat-client-legacy-request', + name: 'ClientLegacyRequestAfterFallback', + description: + 'Dual-era client completes a legacy request after the initialize fallback' + } +] as const; + +type CheckId = (typeof CHECKS)[number]['id']; + +export class VersionBackcompatScenario implements Scenario { + name = 'version-backcompat'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Tests dual-era client fallback from modern Streamable HTTP to +legacy initialization-based MCP. + +The scenario returns HTTP 400 with a generic legacy JSON-RPC error to the +client's first valid modern request. A conformant dual-era client identifies +the server as legacy, performs an initialization-era handshake, and completes +a legacy request.`; + + private server: http.Server | null = null; + private checks: ConformanceCheck[] = []; + private modernProbeObserved = false; + private initializeObserved = false; + + async start(_ctx: ScenarioContext): Promise { + this.modernProbeObserved = false; + this.initializeObserved = false; + this.checks = CHECKS.map((definition) => ({ + ...definition, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'Client did not exercise this compatibility step', + specReferences: SPEC_REFERENCES + })); + + return new Promise((resolve, reject) => { + this.server = http.createServer((request, response) => { + void this.handleRequest(request, response); + }); + this.server.requestTimeout = 5_000; + this.server.headersTimeout = 5_000; + this.server.on('error', reject); + this.server.listen(0, '127.0.0.1', () => { + const address = this.server!.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to determine compatibility server port')); + return; + } + resolve({ serverUrl: `http://127.0.0.1:${address.port}/mcp` }); + }); + }); + } + + async stop(): Promise { + if (!this.server) return; + const server = this.server; + this.server = null; + server.closeAllConnections?.(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + + getChecks(): ConformanceCheck[] { + return this.checks; + } + + private pass(id: CheckId, details?: Record): void { + const check = this.checks.find((candidate) => candidate.id === id); + if (!check) return; + check.status = 'SUCCESS'; + check.errorMessage = undefined; + check.details = details; + } + + private async handleRequest( + request: http.IncomingMessage, + response: http.ServerResponse + ): Promise { + let rawBody: string; + try { + rawBody = await readLimitedRequestBody(request); + } catch (error) { + if (!response.headersSent) { + response.writeHead(error instanceof RangeError ? 413 : 400).end(); + } + return; + } + + const body = parseJsonRecord(rawBody); + if (!body) { + response.writeHead(400).end(); + return; + } + + const params = isRecord(body.params) ? body.params : undefined; + const meta = params && isRecord(params._meta) ? params._meta : undefined; + const declaresDraft = + meta?.['io.modelcontextprotocol/protocolVersion'] === + DRAFT_PROTOCOL_VERSION; + + if (declaresDraft) { + if (isDraftModernProbe(request, body)) { + this.modernProbeObserved = true; + this.pass('version-backcompat-client-modern-probe', { + method: body.method, + protocolVersion: DRAFT_PROTOCOL_VERSION, + mcpMethod: request.headers['mcp-method'] + }); + response.writeHead(400, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32600, message: 'Invalid Request' } + }) + ); + } else { + response.writeHead(400, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id ?? null, + error: { + code: HEADER_MISMATCH, + message: 'Missing or mismatched modern request headers' + } + }) + ); + } + return; + } + + if (body.method === 'initialize') { + const protocolVersion = params?.protocolVersion; + this.initializeObserved = + this.modernProbeObserved && protocolVersion === LEGACY_PROTOCOL_VERSION; + if (this.initializeObserved) { + this.pass('version-backcompat-client-legacy-initialize', { + protocolVersion + }); + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + result: { + protocolVersion: LEGACY_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'legacy-test-server', version: '1.0.0' } + } + }) + ); + return; + } + + if (body.method === 'notifications/initialized') { + response.writeHead(202).end(); + return; + } + + if (body.method === 'tools/list') { + if (this.modernProbeObserved && this.initializeObserved) { + this.pass('version-backcompat-client-legacy-request', { + method: body.method + }); + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + result: { tools: [] } + }) + ); + return; + } + + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: {} })); + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 03cf55f5..410b2b40 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -14,6 +14,7 @@ import { ToolsCallScenario } from './client/tools_call'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults'; import { SSERetryScenario } from './client/sse-retry'; import { RequestMetadataScenario } from './client/request-metadata'; +import { VersionBackcompatScenario } from './client/version-backcompat'; import { MRTRClientScenario } from './client/mrtr-client'; // Import all new server test scenarios @@ -256,7 +257,10 @@ const draftClientScenariosList: ClientScenario[] = allClientScenariosList.filter( (scenario) => 'introducedIn' in scenario.source && - scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION + scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION && + !pendingClientScenariosList.some( + (pending) => pending.name === scenario.name + ) ); // Active client scenarios (excludes pending and draft) @@ -293,6 +297,10 @@ export const clientScenariosForAuthorizationServer = new Map< ]) ); +const protocolBackcompatScenariosList: Scenario[] = [ + new VersionBackcompatScenario() +]; + // All client test scenarios (core + backcompat + extensions) const scenariosList: Scenario[] = [ new InitializeScenario(), @@ -300,6 +308,7 @@ const scenariosList: Scenario[] = [ new ElicitationClientDefaultsScenario(), new SSERetryScenario(), new RequestMetadataScenario(), + ...protocolBackcompatScenariosList, ...authScenariosList, ...backcompatScenariosList, ...draftScenariosList, @@ -381,7 +390,9 @@ export function listExtensionScenarios(): string[] { } export function listBackcompatScenarios(): string[] { - return backcompatScenariosList.map((scenario) => scenario.name); + return [...backcompatScenariosList, ...protocolBackcompatScenariosList].map( + (scenario) => scenario.name + ); } export function listClientScenariosForAuthorizationServer(): string[] { diff --git a/src/scenarios/server/version-backcompat-fixture.test.ts b/src/scenarios/server/version-backcompat-fixture.test.ts new file mode 100644 index 00000000..1268e26b --- /dev/null +++ b/src/scenarios/server/version-backcompat-fixture.test.ts @@ -0,0 +1,170 @@ +import { ChildProcess, spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import path from 'path'; +import { + LEGACY_PROTOCOL_VERSION, + MAX_COMPAT_MESSAGE_BYTES, + probeLegacyHttpFallback +} from '../../version-compat'; + +interface RunningFixture { + processHandle: ChildProcess; + serverUrl: string; +} + +async function startFixture(emitModernError: boolean): Promise { + const script = path.join( + process.cwd(), + 'examples/servers/typescript/legacy-version-server.ts' + ); + const tsxCli = fileURLToPath(import.meta.resolve('tsx/cli')); + const processHandle = spawn(process.execPath, [tsxCli, script], { + env: { + ...process.env, + PORT: '0', + EMIT_MODERN_ERROR: String(emitModernError) + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + const serverUrl = await new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + const timeout = setTimeout(() => { + processHandle.kill('SIGKILL'); + reject(new Error(`Legacy fixture startup timed out: ${stderr}`)); + }, 30_000); + processHandle.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + processHandle.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + const match = stdout.match(/running on (http:\/\/127\.0\.0\.1:\d+\/mcp)/); + if (match?.[1]) { + clearTimeout(timeout); + resolve(match[1]); + } + }); + processHandle.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + processHandle.once('exit', (code) => { + if (code !== null) { + clearTimeout(timeout); + reject(new Error(`Legacy fixture exited with ${code}: ${stderr}`)); + } + }); + }); + + return { processHandle, serverUrl }; +} + +async function stopFixture(processHandle: ChildProcess): Promise { + if (processHandle.exitCode !== null || processHandle.signalCode !== null) { + return; + } + await new Promise((resolve) => { + const timeout = setTimeout(() => { + processHandle.kill('SIGKILL'); + resolve(); + }, 5_000); + processHandle.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + processHandle.kill('SIGTERM'); + }); +} + +async function postJson( + serverUrl: string, + body: Record +): Promise { + return fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +} + +describe('legacy version server example', () => { + test.each([ + { emitModernError: false, expectedKind: 'legacy-fallback' }, + { emitModernError: true, expectedKind: 'modern' } + ] as const)( + 'modern-error mode $emitModernError is classified as $expectedKind', + async ({ emitModernError, expectedKind }) => { + const fixture = await startFixture(emitModernError); + try { + const result = await probeLegacyHttpFallback(fixture.serverUrl); + expect(result.kind).toBe(expectedKind); + } finally { + await stopFixture(fixture.processHandle); + } + }, + 40_000 + ); + + test('supports initialize, initialized notification, and tools/list', async () => { + const fixture = await startFixture(false); + try { + const initialize = await postJson(fixture.serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LEGACY_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'fixture-test', version: '1.0.0' } + } + }); + expect(initialize.status).toBe(200); + await expect(initialize.json()).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 1, + result: { protocolVersion: LEGACY_PROTOCOL_VERSION } + }); + + const initialized = await postJson(fixture.serverUrl, { + jsonrpc: '2.0', + method: 'notifications/initialized' + }); + expect(initialized.status).toBe(202); + + const tools = await postJson(fixture.serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {} + }); + expect(tools.status).toBe(200); + await expect(tools.json()).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 2, + result: { tools: [] } + }); + } finally { + await stopFixture(fixture.processHandle); + } + }, 40_000); + + test('rejects scalar JSON and oversized request bodies without crashing', async () => { + const fixture = await startFixture(false); + try { + const scalar = await fetch(fixture.serverUrl, { + method: 'POST', + body: 'null' + }); + expect(scalar.status).toBe(400); + + const oversized = await fetch(fixture.serverUrl, { + method: 'POST', + body: 'x'.repeat(MAX_COMPAT_MESSAGE_BYTES + 1) + }); + expect(oversized.status).toBe(413); + } finally { + await stopFixture(fixture.processHandle); + } + }, 40_000); +}); diff --git a/src/scenarios/server/version-backcompat.test.ts b/src/scenarios/server/version-backcompat.test.ts new file mode 100644 index 00000000..2241b402 --- /dev/null +++ b/src/scenarios/server/version-backcompat.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + MODERN_PROBE_ID, + probeLegacyHttpFallback, + RECOGNIZED_MODERN_ERROR_CODES +} from '../../version-compat'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +const SERVER_URL = 'http://localhost:3000/mcp'; + +describe('legacy HTTP fallback diagnostic', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + test('classifies a generic HTTP 400 and sends a valid modern probe', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: MODERN_PROBE_ID, + error: { code: -32600, message: 'Invalid Request' } + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + ); + + await expect(probeLegacyHttpFallback(SERVER_URL)).resolves.toEqual({ + kind: 'legacy-fallback', + status: 400 + }); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe(SERVER_URL); + expect(init?.method).toBe('POST'); + const headers = new Headers(init?.headers); + expect(headers.get('accept')).toBe('application/json, text/event-stream'); + expect(headers.get('content-type')).toBe('application/json'); + expect(headers.get('mcp-protocol-version')).toBe(DRAFT_PROTOCOL_VERSION); + expect(headers.get('mcp-method')).toBe('tools/list'); + expect(JSON.parse(String(init?.body))).toMatchObject({ + jsonrpc: '2.0', + id: MODERN_PROBE_ID, + method: 'tools/list', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION + } + } + }); + }); + + test.each([...RECOGNIZED_MODERN_ERROR_CODES])( + 'classifies recognized modern error %i as modern', + async (code) => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: MODERN_PROBE_ID, + error: { code, message: 'Modern protocol error' } + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + ); + + await expect(probeLegacyHttpFallback(SERVER_URL)).resolves.toEqual({ + kind: 'modern', + status: 400, + errorCode: code + }); + } + ); + + test('classifies empty and non-JSON HTTP 400 responses as legacy fallback', async () => { + for (const body of ['', 'legacy endpoint']) { + fetchMock.mockResolvedValueOnce(new Response(body, { status: 400 })); + } + + await expect(probeLegacyHttpFallback(SERVER_URL)).resolves.toMatchObject({ + kind: 'legacy-fallback' + }); + await expect(probeLegacyHttpFallback(SERVER_URL)).resolves.toMatchObject({ + kind: 'legacy-fallback' + }); + }); + + test('does not trust a recognized code in a malformed or mismatched envelope', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 'different-request', + error: { code: -32022, message: 'Wrong response' } + }), + { status: 400 } + ) + ); + + await expect(probeLegacyHttpFallback(SERVER_URL)).resolves.toEqual({ + kind: 'legacy-fallback', + status: 400 + }); + }); + + test.each([200, 401, 403, 404, 405, 500])( + 'does not classify HTTP %i as initialization-era fallback', + async (status) => { + fetchMock.mockResolvedValue(new Response(null, { status })); + await expect(probeLegacyHttpFallback(SERVER_URL)).resolves.toEqual({ + kind: 'other', + status + }); + } + ); + + test('reports transport failures as unavailable diagnostics', async () => { + fetchMock.mockRejectedValue(new Error('connection refused')); + await expect(probeLegacyHttpFallback(SERVER_URL)).resolves.toEqual({ + kind: 'unavailable', + error: 'connection refused' + }); + }); + + test('reports oversized probe responses as unavailable diagnostics', async () => { + fetchMock.mockResolvedValue( + new Response('oversized', { + status: 400, + headers: { 'Content-Length': String(1024 * 1024 + 1) } + }) + ); + await expect(probeLegacyHttpFallback(SERVER_URL)).resolves.toEqual({ + kind: 'unavailable', + error: 'Response body exceeds 1048576 bytes' + }); + }); +}); diff --git a/src/scenarios/spec-version.test.ts b/src/scenarios/spec-version.test.ts index 7e5f4517..36d7caca 100644 --- a/src/scenarios/spec-version.test.ts +++ b/src/scenarios/spec-version.test.ts @@ -4,6 +4,7 @@ import { listScenariosForSpec, listDraftScenarios, listDraftClientScenarios, + listPendingClientScenarios, listActiveClientScenarios, listExtensionScenarios, getScenario, @@ -133,6 +134,7 @@ describe('draft suite membership', () => { } const draftServerTesting = new Set(listDraftClientScenarios()); + const pendingServerTesting = new Set(listPendingClientScenarios()); for (const [name, scenario] of clientScenarios) { if ( 'introducedIn' in scenario.source && @@ -140,8 +142,8 @@ describe('draft suite membership', () => { ) { expect( draftServerTesting.has(name), - `server-testing scenario "${name}" should be in the draft suite` - ).toBe(true); + `server-testing scenario "${name}" has incorrect draft-suite membership` + ).toBe(!pendingServerTesting.has(name)); } } }); diff --git a/src/version-compat.ts b/src/version-compat.ts new file mode 100644 index 00000000..85d78023 --- /dev/null +++ b/src/version-compat.ts @@ -0,0 +1,174 @@ +import type { IncomingMessage } from 'http'; +import { + HEADER_MISMATCH, + MISSING_REQUIRED_CLIENT_CAPABILITY, + UNSUPPORTED_PROTOCOL_VERSION +} from './spec-types/draft'; +import { DRAFT_PROTOCOL_VERSION } from './types'; +import { buildStandardHeaders, withRequestMeta } from './connection/stateless'; + +export const LEGACY_PROTOCOL_VERSION = '2025-11-25' as const; +export const MODERN_PROBE_ID = 'modern-probe' as const; +export const MAX_COMPAT_MESSAGE_BYTES = 1024 * 1024; + +export const RECOGNIZED_MODERN_ERROR_CODES: ReadonlySet = new Set([ + HEADER_MISMATCH, + MISSING_REQUIRED_CLIENT_CAPABILITY, + UNSUPPORTED_PROTOCOL_VERSION +]); + +export type JsonRecord = Record; + +export function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseJsonRecord(text: string): JsonRecord | undefined { + try { + const value: unknown = JSON.parse(text); + return isRecord(value) ? value : undefined; + } catch { + return undefined; + } +} + +export function recognizedModernErrorCode( + value: unknown, + expectedId: string | number +): number | undefined { + if (!isRecord(value) || value.jsonrpc !== '2.0' || value.id !== expectedId) { + return undefined; + } + const error = value.error; + if (!isRecord(error) || typeof error.code !== 'number') return undefined; + return RECOGNIZED_MODERN_ERROR_CODES.has(error.code) ? error.code : undefined; +} + +export function modernProbeRequestInit( + id: string | number = MODERN_PROBE_ID +): RequestInit { + const method = 'tools/list'; + return { + method: 'POST', + headers: buildStandardHeaders(method), + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params: withRequestMeta() + }) + }; +} + +export async function readLimitedRequestBody( + request: IncomingMessage, + maxBytes = MAX_COMPAT_MESSAGE_BYTES +): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > maxBytes) { + throw new RangeError(`Request body exceeds ${maxBytes} bytes`); + } + chunks.push(buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + +export async function readLimitedResponseText( + response: Response, + maxBytes = MAX_COMPAT_MESSAGE_BYTES +): Promise { + const contentLength = response.headers.get('content-length'); + if (contentLength !== null && Number(contentLength) > maxBytes) { + await response.body?.cancel(); + throw new RangeError(`Response body exceeds ${maxBytes} bytes`); + } + if (!response.body) return ''; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel(); + throw new RangeError(`Response body exceeds ${maxBytes} bytes`); + } + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); + } finally { + reader.releaseLock(); + } +} + +export type HttpFallbackClassification = + | { kind: 'legacy-fallback'; status: 400 } + | { kind: 'modern'; status: 400; errorCode: number } + | { kind: 'other'; status: number } + | { kind: 'unavailable'; error: string }; + +export async function classifyHttpFallbackResponse( + response: Response, + expectedId: string | number +): Promise { + if (response.status !== 400) { + await response.body?.cancel(); + return { kind: 'other', status: response.status }; + } + + const text = await readLimitedResponseText(response); + const body = parseJsonRecord(text); + const contentType = response.headers + .get('content-type') + ?.split(';', 1)[0] + ?.trim() + .toLowerCase(); + const errorCode = + contentType === 'application/json' + ? recognizedModernErrorCode(body, expectedId) + : undefined; + return errorCode === undefined + ? { kind: 'legacy-fallback', status: 400 } + : { kind: 'modern', status: 400, errorCode }; +} + +export async function probeLegacyHttpFallback( + serverUrl: string, + fetchImpl: typeof fetch = fetch +): Promise { + try { + const response = await fetchImpl( + serverUrl, + modernProbeRequestInit(MODERN_PROBE_ID) + ); + return await classifyHttpFallbackResponse(response, MODERN_PROBE_ID); + } catch (error) { + return { + kind: 'unavailable', + error: error instanceof Error ? error.message : String(error) + }; + } +} + +export function isDraftModernProbe( + request: IncomingMessage, + body: JsonRecord +): boolean { + const params = isRecord(body.params) ? body.params : undefined; + const meta = params && isRecord(params._meta) ? params._meta : undefined; + return ( + request.method === 'POST' && + request.headers['mcp-protocol-version'] === DRAFT_PROTOCOL_VERSION && + request.headers['mcp-method'] === 'tools/list' && + body.method === 'tools/list' && + meta?.['io.modelcontextprotocol/protocolVersion'] === DRAFT_PROTOCOL_VERSION + ); +}