From 132a5d6ecc4e85fd46ecfed9705be7929208d33d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 26 May 2026 11:19:09 +0100 Subject: [PATCH 1/4] feat(everything-server): serve resources and caching hints on the stateless draft path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stateless (SEP-2575) request path now answers resources/list, resources/templates/list and resources/read — including the SEP-2164 -32602 + data.uri error for unknown URIs — and includes the SEP-2549 ttlMs/cacheScope hints on its cacheable list results, matching what the draft-spec scenarios exercise. --- .../servers/typescript/everything-server.ts | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 1d9dcc96..20a5ac85 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1371,7 +1371,10 @@ app.post('/mcp', async (req, res) => { description: 'Diagnostic logging validator tool', inputSchema: { type: 'object', properties: {} } } - ] + ], + // SEP-2549 caching hints are required on cacheable list results. + ttlMs: 300000, + cacheScope: 'public' } }); } @@ -1387,7 +1390,9 @@ app.post('/mcp', async (req, res) => { name: 'test_input_required_result_prompt', description: 'MRTR: prompt that requires elicitation input' } - ] + ], + ttlMs: 300000, + cacheScope: 'public' } }); } @@ -1442,6 +1447,69 @@ app.post('/mcp', async (req, res) => { } } + // Resources on the stateless draft path (SEP-2549 hints + SEP-2164 errors). + if (method === 'resources/list') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + resources: [ + { + uri: 'test://stateless-static-text', + name: 'Stateless Static Text', + description: 'A static text resource served on the draft path', + mimeType: 'text/plain' + } + ], + ttlMs: 300000, + cacheScope: 'public' + } + }); + } + + if (method === 'resources/templates/list') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + resourceTemplates: [], + ttlMs: 300000, + cacheScope: 'public' + } + }); + } + + if (method === 'resources/read') { + const uri = params.uri as string | undefined; + if (uri === 'test://stateless-static-text') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + contents: [ + { + uri, + mimeType: 'text/plain', + text: 'Static text content from the stateless draft path.' + } + ], + ttlMs: 300000, + cacheScope: 'private' + } + }); + } + // SEP-2164: unknown resources get -32602 with the requested uri in data. + return res.status(200).json({ + jsonrpc: '2.0', + id, + error: { + code: -32602, + message: 'Resource not found', + data: { uri } + } + }); + } + if (method === 'tools/call') { const name = params.name; const inputResponses = params.inputResponses as From acc57a1515c24c61d36aa786ce1d1fb4fc7da2af Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 26 May 2026 11:19:09 +0100 Subject: [PATCH 2/4] feat: make the harness's own draft-spec traffic conformant Fixes the class of issues where a strictly-conformant SDK rejects the harness's requests for reasons unrelated to the behaviour under test (#311, #312, #315). - add a shared stateless draft request helper (draft-client.ts) that puts the cross-cutting SEP-2243 headers (Mcp-Method, Mcp-Name, MCP-Protocol-Version, Accept) and SEP-2575 _meta (protocolVersion, clientInfo, clientCapabilities) on every request, with overrides for negative cases and the SHOULD-level retry on UnsupportedProtocolVersionError - migrate the draft scenarios onto it: the SEP-2322 helpers and server-stateless now send Mcp-Method/Mcp-Name (#312); the SEP-2243 server scenarios drop the legacy initialize handshake and carry _meta on every raw request (#311); caching and sep-2164-resource-not-found are stateless DRAFT-2026-v1 requests instead of an SDK 2025-11-25 session (#315) - add a draft self-conformance gate (draft-self-conformance.test.ts) that points the harness's own draft drivers at the existing client-testing judge scenarios (request-metadata, http-standard-headers) and requires zero failures or warnings - align the request-metadata mock's unsupported-version rejection with the draft schema (-32004) and teach the everything-client to recognize it - rewrite the SEP-2549/SEP-2164 negative example servers as stateless draft servers so they keep exercising the violation, not the transport --- .../clients/typescript/everything-client.ts | 7 +- .../typescript/sep-2164-empty-contents.ts | 72 ++-- .../typescript/sep-2549-no-caching-hints.ts | 211 ++++------ src/scenarios/client/request-metadata.test.ts | 3 +- src/scenarios/client/request-metadata.ts | 3 +- src/scenarios/draft-self-conformance.test.ts | 99 +++++ src/scenarios/server/caching.ts | 387 +++++++----------- src/scenarios/server/draft-client.ts | 340 +++++++++++++++ src/scenarios/server/http-standard-headers.ts | 125 ++---- .../server/input-required-result-helpers.ts | 45 +- src/scenarios/server/resources.ts | 56 ++- src/scenarios/server/stateless.ts | 20 +- 12 files changed, 786 insertions(+), 582 deletions(-) create mode 100644 src/scenarios/draft-self-conformance.test.ts create mode 100644 src/scenarios/server/draft-client.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 74168f05..63ca051b 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -155,7 +155,12 @@ async function runRequestMetadataClient(serverUrl: string): Promise { const clone = response.clone(); try { const errorResult = await clone.json(); - if (errorResult.error?.code === -32001) { + // -32004 is UnsupportedProtocolVersionError in the draft schema; + // -32001 is tolerated for servers that predate the dedicated code. + if ( + errorResult.error?.code === -32004 || + errorResult.error?.code === -32001 + ) { logger.debug( 'Received UnsupportedProtocolVersionError, starting negotiation...' ); diff --git a/examples/servers/typescript/sep-2164-empty-contents.ts b/examples/servers/typescript/sep-2164-empty-contents.ts index 6ec1bdfc..ffb28854 100644 --- a/examples/servers/typescript/sep-2164-empty-contents.ts +++ b/examples/servers/typescript/sep-2164-empty-contents.ts @@ -3,58 +3,44 @@ /** * SEP-2164 Negative Test Server * - * Returns an empty contents array for any resources/read request, violating - * the SEP-2164 MUST NOT. The sep-2164-resource-not-found scenario should - * emit FAILURE for sep-2164-no-empty-contents against this server. + * Speaks the stateless draft wire protocol (SEP-2575) but returns an empty + * contents array for any resources/read request, violating the SEP-2164 MUST + * NOT. The sep-2164-resource-not-found scenario should emit FAILURE for + * sep-2164-no-empty-contents against this server. */ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - ListResourcesRequestSchema, - ReadResourceRequestSchema -} from '@modelcontextprotocol/sdk/types.js'; import express from 'express'; -function createServer() { - const server = new Server( - { name: 'sep-2164-empty-contents', version: '1.0.0' }, - { capabilities: { resources: {} } } - ); - - server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: [] - })); - - server.setRequestHandler(ReadResourceRequestSchema, async () => ({ - contents: [] - })); - - return server; -} - const app = express(); app.use(express.json()); -app.post('/mcp', async (req, res) => { - try { - const server = createServer(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - } catch (error) { - if (!res.headersSent) { - res.status(500).json({ +app.post('/mcp', (req, res) => { + const body = req.body || {}; + const id = body.id ?? null; + const method = body.method; + + switch (method) { + case 'server/discover': + return res.json({ + jsonrpc: '2.0', + id, + result: { + supportedVersions: ['DRAFT-2026-v1'], + capabilities: { resources: {} }, + serverInfo: { name: 'sep-2164-empty-contents', version: '1.0.0' } + } + }); + case 'resources/list': + return res.json({ jsonrpc: '2.0', id, result: { resources: [] } }); + case 'resources/read': + // Deliberately return an empty contents array instead of an error. + return res.json({ jsonrpc: '2.0', id, result: { contents: [] } }); + default: + return res.status(404).json({ jsonrpc: '2.0', - error: { - code: -32603, - message: `Internal error: ${error instanceof Error ? error.message : String(error)}` - }, - id: null + id, + error: { code: -32601, message: 'Method not found' } }); - } } }); diff --git a/examples/servers/typescript/sep-2549-no-caching-hints.ts b/examples/servers/typescript/sep-2549-no-caching-hints.ts index 8fe7952d..78c861b8 100644 --- a/examples/servers/typescript/sep-2549-no-caching-hints.ts +++ b/examples/servers/typescript/sep-2549-no-caching-hints.ts @@ -3,151 +3,96 @@ /** * SEP-2549 Negative Test Server * - * Returns list and read results WITHOUT ttlMs and cacheScope fields, - * violating the SEP-2549 MUST. The caching scenario should emit FAILURE - * for presence checks against this server. + * Speaks the stateless draft wire protocol (SEP-2575) but returns list and + * read results WITHOUT ttlMs and cacheScope fields, violating the SEP-2549 + * MUST. The caching scenario should emit FAILURE for presence checks against + * this server. */ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - ListToolsRequestSchema, - ListPromptsRequestSchema, - ListResourcesRequestSchema, - ListResourceTemplatesRequestSchema, - ReadResourceRequestSchema -} from '@modelcontextprotocol/sdk/types.js'; import express from 'express'; -import { randomUUID } from 'crypto'; - -const transports: Record = {}; - -function isInitializeRequest(body: any): boolean { - return body?.method === 'initialize'; -} - -function createServer() { - const server = new Server( - { name: 'sep-2549-no-caching-hints', version: '1.0.0' }, - { - capabilities: { - tools: {}, - resources: {}, - prompts: {} - } - } - ); - - // Deliberately omit ttlMs and cacheScope from all responses - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test_tool', - description: 'A test tool', - inputSchema: { type: 'object' as const } - } - ] - })); - - server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: [ - { - name: 'test_prompt', - description: 'A test prompt' - } - ] - })); - - server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: [ - { - uri: 'test://static-text', - name: 'Static Text', - description: 'A static text resource' - } - ] - })); - - server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ - resourceTemplates: [] - })); - - server.setRequestHandler(ReadResourceRequestSchema, async () => ({ - contents: [ - { - uri: 'test://static-text', - mimeType: 'text/plain', - text: 'Static text content.' - } - ] - })); - - return server; -} const app = express(); app.use(express.json()); -app.post('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - try { - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res, req.body); - return; - } +app.post('/mcp', (req, res) => { + const body = req.body || {}; + const id = body.id ?? null; + const method = body.method; - if (!sessionId && isInitializeRequest(req.body)) { - const server = createServer(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (newSessionId) => { - transports[newSessionId] = transport; + // Deliberately omit ttlMs and cacheScope from every result below. + switch (method) { + case 'server/discover': + return res.json({ + jsonrpc: '2.0', + id, + result: { + supportedVersions: ['DRAFT-2026-v1'], + capabilities: { tools: {}, resources: {}, prompts: {} }, + serverInfo: { name: 'sep-2549-no-caching-hints', version: '1.0.0' } } }); - transport.onclose = () => { - const sid = transport.sessionId; - if (sid) delete transports[sid]; - }; - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - return; - } - - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Invalid or missing session ID' }, - id: null - }); - } catch (error) { - if (!res.headersSent) { - res.status(500).json({ + case 'tools/list': + return res.json({ jsonrpc: '2.0', - error: { - code: -32603, - message: `Internal error: ${error instanceof Error ? error.message : String(error)}` - }, - id: null + id, + result: { + tools: [ + { + name: 'test_tool', + description: 'A test tool', + inputSchema: { type: 'object' } + } + ] + } + }); + case 'prompts/list': + return res.json({ + jsonrpc: '2.0', + id, + result: { + prompts: [{ name: 'test_prompt', description: 'A test prompt' }] + } + }); + case 'resources/list': + return res.json({ + jsonrpc: '2.0', + id, + result: { + resources: [ + { + uri: 'test://static-text', + name: 'Static Text', + description: 'A static text resource' + } + ] + } + }); + case 'resources/templates/list': + return res.json({ + jsonrpc: '2.0', + id, + result: { resourceTemplates: [] } + }); + case 'resources/read': + return res.json({ + jsonrpc: '2.0', + id, + result: { + contents: [ + { + uri: 'test://static-text', + mimeType: 'text/plain', + text: 'Static text content.' + } + ] + } + }); + default: + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' } }); - } - } -}); - -app.get('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res); - } else { - res.status(400).json({ error: 'Invalid or missing session ID' }); - } -}); - -app.delete('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res); - } else { - res.status(400).json({ error: 'Invalid or missing session ID' }); } }); diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts index 69384648..b2f9b637 100644 --- a/src/scenarios/client/request-metadata.test.ts +++ b/src/scenarios/client/request-metadata.test.ts @@ -104,9 +104,10 @@ async function incompatibleVersionClient(serverUrl: string) { if (response.status === 400) { const body = await response.json(); - if (body.error?.code === -32001) { + if (body.error?.code === -32004 || body.error?.code === -32001) { return body; // Abort cleanly } + return body; } return response.json(); } diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 47d4f00a..0fee696f 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -256,7 +256,8 @@ export class RequestMetadataScenario implements Scenario { jsonrpc: '2.0', id: request.id ?? null, error: { - code: -32001, + // UnsupportedProtocolVersionError per the draft schema. + code: -32004, message: 'Unsupported protocol version', data: { supported: [DRAFT_PROTOCOL_VERSION] diff --git a/src/scenarios/draft-self-conformance.test.ts b/src/scenarios/draft-self-conformance.test.ts new file mode 100644 index 00000000..125c495c --- /dev/null +++ b/src/scenarios/draft-self-conformance.test.ts @@ -0,0 +1,99 @@ +/** + * Draft self-conformance gate: "test conformance with conformance". + * + * The harness's own draft-spec server-scenario drivers (the requests we send + * when testing an SDK server) must satisfy the cross-cutting draft client + * obligations — otherwise a strictly-conformant SDK rejects harness traffic + * for reasons unrelated to the behaviour under test (issues #311, #312, #315). + * + * Rather than writing bespoke assertions, each pairing starts an existing + * client-testing Scenario as the judge (a mock server that inspects every + * incoming request and emits conformance checks) and points a draft + * ClientScenario driver at it. The driver's own checks are irrelevant here — + * the judge's checks are the assertion. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { getClientScenario } from './index'; +import { HttpStandardHeadersScenario } from './client/http-standard-headers'; +import { RequestMetadataScenario } from './client/request-metadata'; +import type { Scenario } from '../types'; + +/** + * judge scenario -> driver scenarios. + * + * Judges are instantiated fresh for every pairing (the registry instances are + * module-level singletons whose recorded checks would otherwise leak between + * pairings). + * + * Only positive draft drivers are paired with a given judge: scenarios that + * deliberately send traffic violating that judge's dimension (e.g. + * server-stateless's invalid-_meta cases against the SEP-2575 judge, or + * http-header-validation's mangled headers against the SEP-2243 judge) are + * excluded from that judge's row. + */ +const JUDGES: Record Scenario> = { + // SEP-2243: Mcp-Method / Mcp-Name headers on every request + 'http-standard-headers': () => new HttpStandardHeadersScenario(), + // SEP-2575: MCP-Protocol-Version header + complete _meta on every request + 'request-metadata': () => new RequestMetadataScenario() +}; + +const PAIRINGS: Record = { + 'http-standard-headers': [ + 'caching', + 'input-required-result-basic-elicitation', + 'sep-2164-resource-not-found', + 'server-stateless' + ], + 'request-metadata': [ + 'caching', + 'input-required-result-basic-elicitation', + 'sep-2164-resource-not-found' + ] +}; + +describe('draft self-conformance (harness traffic judged by client scenarios)', () => { + let judge: Scenario | undefined; + + afterEach(async () => { + if (judge) { + await judge.stop().catch(() => {}); + judge = undefined; + } + }); + + for (const [judgeName, driverNames] of Object.entries(PAIRINGS)) { + for (const driverName of driverNames) { + it(`${driverName} traffic passes the ${judgeName} checks`, async () => { + judge = JUDGES[judgeName](); + expect(judge, `judge scenario ${judgeName} not found`).toBeDefined(); + const driver = getClientScenario(driverName); + expect(driver, `driver scenario ${driverName} not found`).toBeDefined(); + + const urls = await judge!.start(); + try { + // The judge's mock is not a real MCP server, so the driver's own + // checks routinely fail against it — that is expected and ignored. + // Only the traffic the driver emitted matters here. + await driver!.run(urls.serverUrl).catch(() => {}); + } finally { + await judge!.stop(); + } + + const verdicts = judge! + .getChecks() + .filter((c) => c.status === 'FAILURE' || c.status === 'WARNING'); + expect( + verdicts, + `harness traffic from "${driverName}" violated draft client obligations:\n` + + verdicts + .map( + (c) => + ` ${c.id} [${c.status}] ${c.name}: ${c.errorMessage ?? c.description}` + ) + .join('\n') + ).toEqual([]); + }, 30000); + } + } +}); diff --git a/src/scenarios/server/caching.ts b/src/scenarios/server/caching.ts index 5c580023..53a20ff4 100644 --- a/src/scenarios/server/caching.ts +++ b/src/scenarios/server/caching.ts @@ -10,14 +10,7 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { connectToServer } from './client-helper'; -import { - ListToolsResultSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ReadResourceResultSchema -} from '@modelcontextprotocol/sdk/types.js'; +import { sendDraftRequest } from './draft-client'; const SPEC_REFS = [ { @@ -98,259 +91,177 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p const checks: ConformanceCheck[] = []; const allFields: Array<{ endpoint: string; fields: CachingFields }> = []; - try { - const connection = await connectToServer(serverUrl); - - // 1. tools/list - try { - const toolsResult = await connection.client.request( - { method: 'tools/list', params: {} }, - ListToolsResultSchema - ); - const fields = extractCachingFields( - toolsResult as Record - ); - allFields.push({ endpoint: 'tools/list', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-tools-list-caching-hints', - 'ToolsListCachingHints', - 'tools/list', - fields - ) - ); - } catch (error) { - checks.push({ - id: 'sep-2549-tools-list-caching-hints', - name: 'ToolsListCachingHints', - description: - 'tools/list response includes ttlMs and cacheScope caching hints', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `tools/list request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS - }); - } - - // 2. prompts/list - try { - const promptsResult = await connection.client.request( - { method: 'prompts/list', params: {} }, - ListPromptsResultSchema - ); - const fields = extractCachingFields( - promptsResult as Record - ); - allFields.push({ endpoint: 'prompts/list', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-prompts-list-caching-hints', - 'PromptsListCachingHints', - 'prompts/list', - fields - ) - ); - } catch (error) { - checks.push({ - id: 'sep-2549-prompts-list-caching-hints', - name: 'PromptsListCachingHints', - description: - 'prompts/list response includes ttlMs and cacheScope caching hints', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `prompts/list request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS - }); - } - - // 3. resources/list - let firstResourceUri: string | undefined; + // SEP-2549 only exists in the draft spec, so each cacheable endpoint is + // queried with a stateless draft request: protocolVersion DRAFT-2026-v1 + // plus the cross-cutting _meta and standard headers (issue #315). + const queryEndpoint = async ( + checkId: string, + checkName: string, + endpoint: string, + params?: Record + ): Promise | undefined> => { + const description = `${endpoint} response includes ttlMs and cacheScope caching hints`; try { - const resourcesResult = await connection.client.request( - { method: 'resources/list', params: {} }, - ListResourcesResultSchema - ); - const fields = extractCachingFields( - resourcesResult as Record - ); - allFields.push({ endpoint: 'resources/list', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-resources-list-caching-hints', - 'ResourcesListCachingHints', - 'resources/list', - fields - ) - ); - // Capture the first resource URI for the resources/read check - if (resourcesResult.resources && resourcesResult.resources.length > 0) { - firstResourceUri = resourcesResult.resources[0].uri; + const response = await sendDraftRequest(serverUrl, endpoint, params); + const result = response.body?.result; + if (!result) { + const error = response.body?.error; + checks.push({ + id: checkId, + name: checkName, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: error + ? `${endpoint} returned JSON-RPC error ${error.code}: ${error.message}` + : `${endpoint} returned HTTP ${response.status} with no result`, + specReferences: SPEC_REFS, + details: { httpStatus: response.status, error } + }); + return undefined; } + const fields = extractCachingFields(result); + allFields.push({ endpoint, fields }); + checks.push(buildPresenceCheck(checkId, checkName, endpoint, fields)); + return result; } catch (error) { checks.push({ - id: 'sep-2549-resources-list-caching-hints', - name: 'ResourcesListCachingHints', - description: - 'resources/list response includes ttlMs and cacheScope caching hints', + id: checkId, + name: checkName, + description, status: 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: `resources/list request failed: ${error instanceof Error ? error.message : String(error)}`, + errorMessage: `${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`, specReferences: SPEC_REFS }); + return undefined; } + }; - // 4. resources/templates/list - try { - const templatesResult = await connection.client.request( - { method: 'resources/templates/list', params: {} }, - ListResourceTemplatesResultSchema - ); - const fields = extractCachingFields( - templatesResult as Record - ); - allFields.push({ endpoint: 'resources/templates/list', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-resources-templates-list-caching-hints', - 'ResourcesTemplatesListCachingHints', - 'resources/templates/list', - fields - ) - ); - } catch (error) { - checks.push({ - id: 'sep-2549-resources-templates-list-caching-hints', - name: 'ResourcesTemplatesListCachingHints', - description: - 'resources/templates/list response includes ttlMs and cacheScope caching hints', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `resources/templates/list request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS - }); - } + // 1. tools/list + await queryEndpoint( + 'sep-2549-tools-list-caching-hints', + 'ToolsListCachingHints', + 'tools/list' + ); - // 5. resources/read — use first resource from resources/list - if (firstResourceUri) { - try { - const readResult = await connection.client.request( - { - method: 'resources/read', - params: { uri: firstResourceUri } - }, - ReadResourceResultSchema - ); - const fields = extractCachingFields( - readResult as Record - ); - allFields.push({ endpoint: 'resources/read', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-resources-read-caching-hints', - 'ResourcesReadCachingHints', - 'resources/read', - fields - ) - ); - } catch (error) { - checks.push({ - id: 'sep-2549-resources-read-caching-hints', - name: 'ResourcesReadCachingHints', - description: - 'resources/read response includes ttlMs and cacheScope caching hints', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `resources/read request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS - }); - } - } + // 2. prompts/list + await queryEndpoint( + 'sep-2549-prompts-list-caching-hints', + 'PromptsListCachingHints', + 'prompts/list' + ); - // 6. Aggregate: ttlMs must be a non-negative integer - const ttlErrors: string[] = []; - const endpointsWithTtl = allFields.filter((f) => f.fields.hasTtlMs); - if (endpointsWithTtl.length === 0) { - ttlErrors.push('no endpoints returned ttlMs'); - } else { - for (const { endpoint, fields } of endpointsWithTtl) { - const val = fields.ttlMs; - if (typeof val !== 'number') { - ttlErrors.push( - `${endpoint}: ttlMs is ${typeof val}, expected number` - ); - } else if (!Number.isInteger(val)) { - ttlErrors.push(`${endpoint}: ttlMs is ${val}, expected integer`); - } else if (val < 0) { - ttlErrors.push(`${endpoint}: ttlMs is ${val}, must be >= 0`); - } - } - } + // 3. resources/list + const resourcesResult = await queryEndpoint( + 'sep-2549-resources-list-caching-hints', + 'ResourcesListCachingHints', + 'resources/list' + ); + // 4. resources/templates/list + await queryEndpoint( + 'sep-2549-resources-templates-list-caching-hints', + 'ResourcesTemplatesListCachingHints', + 'resources/templates/list' + ); + + // 5. resources/read — use first resource from resources/list + const resources = resourcesResult?.resources as + | Array<{ uri?: string }> + | undefined; + const firstResourceUri = resources?.[0]?.uri; + if (firstResourceUri) { + await queryEndpoint( + 'sep-2549-resources-read-caching-hints', + 'ResourcesReadCachingHints', + 'resources/read', + { uri: firstResourceUri } + ); + } else { + // Keep the emitted check-ID set stable even when there is nothing to + // read (resources/list failed or the server exposes no resources). checks.push({ - id: 'sep-2549-ttl-non-negative', - name: 'TtlNonNegative', - description: 'All ttlMs values are non-negative integers', - status: ttlErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + id: 'sep-2549-resources-read-caching-hints', + name: 'ResourcesReadCachingHints', + description: + 'resources/read response includes ttlMs and cacheScope caching hints', + status: 'SKIPPED', timestamp: new Date().toISOString(), - errorMessage: ttlErrors.length > 0 ? ttlErrors.join('; ') : undefined, - specReferences: SPEC_REFS, - details: { - endpoints: allFields.map((f) => ({ - endpoint: f.endpoint, - ttlMs: f.fields.ttlMs - })) - } + errorMessage: + 'resources/read was not exercised: resources/list failed or returned no resources.', + specReferences: SPEC_REFS }); + } - // 7. Aggregate: cacheScope must be "public" or "private" - const scopeErrors: string[] = []; - const endpointsWithScope = allFields.filter( - (f) => f.fields.hasCacheScope - ); - if (endpointsWithScope.length === 0) { - scopeErrors.push('no endpoints returned cacheScope'); - } else { - for (const { endpoint, fields } of endpointsWithScope) { - const val = fields.cacheScope; - if (val !== 'public' && val !== 'private') { - scopeErrors.push( - `${endpoint}: cacheScope is ${JSON.stringify(val)}, expected "public" or "private"` - ); - } + // 6. Aggregate: ttlMs must be a non-negative integer + const ttlErrors: string[] = []; + const endpointsWithTtl = allFields.filter((f) => f.fields.hasTtlMs); + if (endpointsWithTtl.length === 0) { + ttlErrors.push('no endpoints returned ttlMs'); + } else { + for (const { endpoint, fields } of endpointsWithTtl) { + const val = fields.ttlMs; + if (typeof val !== 'number') { + ttlErrors.push( + `${endpoint}: ttlMs is ${typeof val}, expected number` + ); + } else if (!Number.isInteger(val)) { + ttlErrors.push(`${endpoint}: ttlMs is ${val}, expected integer`); + } else if (val < 0) { + ttlErrors.push(`${endpoint}: ttlMs is ${val}, must be >= 0`); } } + } - checks.push({ - id: 'sep-2549-cache-scope-valid', - name: 'CacheScopeValid', - description: 'All cacheScope values are "public" or "private"', - status: scopeErrors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: - scopeErrors.length > 0 ? scopeErrors.join('; ') : undefined, - specReferences: SPEC_REFS, - details: { - endpoints: allFields.map((f) => ({ - endpoint: f.endpoint, - cacheScope: f.fields.cacheScope - })) - } - }); + checks.push({ + id: 'sep-2549-ttl-non-negative', + name: 'TtlNonNegative', + description: 'All ttlMs values are non-negative integers', + status: ttlErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: ttlErrors.length > 0 ? ttlErrors.join('; ') : undefined, + specReferences: SPEC_REFS, + details: { + endpoints: allFields.map((f) => ({ + endpoint: f.endpoint, + ttlMs: f.fields.ttlMs + })) + } + }); - await connection.close(); - } catch (error) { - // Connection-level failure — push a single failure check - checks.push({ - id: 'sep-2549-caching-connection', - name: 'CachingConnection', - description: 'Caching hints scenario failed to connect', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `Connection failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS - }); + // 7. Aggregate: cacheScope must be "public" or "private" + const scopeErrors: string[] = []; + const endpointsWithScope = allFields.filter((f) => f.fields.hasCacheScope); + if (endpointsWithScope.length === 0) { + scopeErrors.push('no endpoints returned cacheScope'); + } else { + for (const { endpoint, fields } of endpointsWithScope) { + const val = fields.cacheScope; + if (val !== 'public' && val !== 'private') { + scopeErrors.push( + `${endpoint}: cacheScope is ${JSON.stringify(val)}, expected "public" or "private"` + ); + } + } } + checks.push({ + id: 'sep-2549-cache-scope-valid', + name: 'CacheScopeValid', + description: 'All cacheScope values are "public" or "private"', + status: scopeErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: scopeErrors.length > 0 ? scopeErrors.join('; ') : undefined, + specReferences: SPEC_REFS, + details: { + endpoints: allFields.map((f) => ({ + endpoint: f.endpoint, + cacheScope: f.fields.cacheScope + })) + } + }); + return checks; } } diff --git a/src/scenarios/server/draft-client.ts b/src/scenarios/server/draft-client.ts new file mode 100644 index 00000000..cec23432 --- /dev/null +++ b/src/scenarios/server/draft-client.ts @@ -0,0 +1,340 @@ +/** + * Stateless draft-spec request helpers for server scenarios. + * + * The draft spec makes the protocol stateless (SEP-2575) and standardizes the + * HTTP header layer (SEP-2243). Every request the harness sends to a server + * under test therefore has cross-cutting obligations that are independent of + * whatever a scenario is actually testing: + * + * - `MCP-Protocol-Version` header on every POST, matching + * `_meta["io.modelcontextprotocol/protocolVersion"]` in the body + * - `Mcp-Method` header mirroring the JSON-RPC `method` + * - `Mcp-Name` header mirroring `params.name` (tools/call, prompts/get) or + * `params.uri` (resources/read) + * - `_meta` carrying protocolVersion, clientInfo and clientCapabilities + * - an `Accept` header listing both `application/json` and `text/event-stream` + * + * Draft scenarios MUST build their requests through these helpers so a + * strictly-conformant server never rejects harness traffic for reasons + * unrelated to the behaviour under test (issues #311, #312, #315). Negative + * tests can override or omit exactly the dimension they exercise via the + * options. + * + * The harness's own conformance is enforced by + * `src/scenarios/draft-self-conformance.test.ts`. + */ + +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +// ─── JSON-RPC types ────────────────────────────────────────────────────────── + +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number | string | null; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +export const DRAFT_CLIENT_INFO = { + name: 'conformance-test-client', + version: '1.0.0' +} as const; + +export const DRAFT_CLIENT_CAPABILITIES = { + sampling: {}, + elicitation: {}, + roots: { listChanged: true } +} as const; + +// ─── Options ───────────────────────────────────────────────────────────────── + +export interface DraftHeaderOptions { + /** Wire protocol version to advertise (header + _meta). */ + protocolVersion?: string; + /** Extra or overriding headers (later wins; case preserved as given). */ + headers?: Record; + /** Default header names to drop entirely (case-insensitive). */ + omitHeaders?: string[]; +} + +export interface DraftRequestOptions extends DraftHeaderOptions { + /** JSON-RPC id; auto-incremented when omitted. */ + id?: number | string; + /** + * Extra `_meta` keys merged over the conformant defaults, or `false` to + * omit `_meta` entirely (negative tests only). Keys already present in + * `params._meta` also override the defaults. + */ + meta?: Record | false; + /** Client capabilities advertised in `_meta`; defaults to all optional ones. */ + clientCapabilities?: Record; + /** + * Retry once with a server-supported version when the request is rejected + * as an unsupported protocol version (the spec SHOULD for clients). + * Defaults to true. + */ + retryOnUnsupportedVersion?: boolean; + /** Abort the request after this many milliseconds. Defaults to 10s. */ + timeoutMs?: number; +} + +export interface DraftResponse { + status: number; + headers: Headers; + contentType?: string; + /** + * The parsed JSON-RPC message: the JSON body, or — for `text/event-stream` + * responses — the event matching the request id (falling back to the last + * parsed event). + */ + body?: JsonRpcResponse; + /** All parsed events when the response was an SSE / chunked stream. */ + events?: unknown[]; + /** Raw response text when it could not be parsed as JSON. */ + text?: string; +} + +let nextRequestId = 1; + +// ─── Header construction ───────────────────────────────────────────────────── + +/** + * The `Mcp-Name` source field per SEP-2243: `params.name` for tools/call and + * prompts/get, `params.uri` for resources/read; absent otherwise. + */ +export function mcpNameForRequest( + method: string, + params?: Record +): string | undefined { + if (method === 'tools/call' || method === 'prompts/get') { + return typeof params?.name === 'string' ? params.name : undefined; + } + if (method === 'resources/read') { + return typeof params?.uri === 'string' ? params.uri : undefined; + } + return undefined; +} + +/** + * Build the conformant header set for a draft request: Content-Type, Accept + * (both content types), MCP-Protocol-Version, Mcp-Method and (when the method + * carries one) Mcp-Name. Overrides win over defaults; omitHeaders removes + * defaults entirely. + */ +export function buildDraftHeaders( + method: string, + params?: Record, + options: DraftHeaderOptions = {} +): Record { + const protocolVersion = options.protocolVersion ?? DRAFT_PROTOCOL_VERSION; + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': protocolVersion, + 'Mcp-Method': method + }; + const name = mcpNameForRequest(method, params); + if (name !== undefined) { + headers['Mcp-Name'] = name; + } + + if (options.omitHeaders) { + const omit = new Set(options.omitHeaders.map((h) => h.toLowerCase())); + for (const key of Object.keys(headers)) { + if (omit.has(key.toLowerCase())) delete headers[key]; + } + } + + if (options.headers) { + for (const [key, value] of Object.entries(options.headers)) { + // Replace any default that differs only by case, then set the override. + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === key.toLowerCase()) { + delete headers[existing]; + } + } + headers[key] = value; + } + } + + return headers; +} + +// ─── Body construction ─────────────────────────────────────────────────────── + +/** Build the conformant `_meta` object required on every draft request. */ +export function buildDraftMeta( + overrides?: Record, + protocolVersion: string = DRAFT_PROTOCOL_VERSION, + clientCapabilities: Record = DRAFT_CLIENT_CAPABILITIES +): Record { + return { + 'io.modelcontextprotocol/protocolVersion': protocolVersion, + 'io.modelcontextprotocol/clientInfo': DRAFT_CLIENT_INFO, + 'io.modelcontextprotocol/clientCapabilities': clientCapabilities, + ...overrides + }; +} + +/** Merge params with the conformant `_meta` (or omit it when meta === false). */ +export function buildDraftParams( + params: Record | undefined, + options: DraftRequestOptions = {} +): Record | undefined { + if (options.meta === false) { + return params; + } + const protocolVersion = options.protocolVersion ?? DRAFT_PROTOCOL_VERSION; + return { + ...params, + _meta: buildDraftMeta( + { + ...(params?._meta as Record | undefined), + ...(options.meta ?? undefined) + }, + protocolVersion, + options.clientCapabilities ?? DRAFT_CLIENT_CAPABILITIES + ) + }; +} + +// ─── Response parsing ──────────────────────────────────────────────────────── + +function parseSseEvents(text: string): unknown[] { + const events: unknown[] = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const jsonText = line.startsWith('data:') + ? line.replace(/^data:\s*/, '') + : line; + try { + events.push(JSON.parse(jsonText)); + } catch { + // Non-JSON line (comments, partial frames) — ignore. + } + } + return events; +} + +function isUnsupportedVersionRejection( + status: number, + body: JsonRpcResponse | undefined +): string[] | undefined { + if (status !== 400 || !body?.error) return undefined; + const data = body.error.data as { supported?: unknown } | undefined; + if (Array.isArray(data?.supported) && data.supported.length > 0) { + return data.supported.filter((v): v is string => typeof v === 'string'); + } + return undefined; +} + +// ─── Requests ──────────────────────────────────────────────────────────────── + +/** + * Send a single stateless draft JSON-RPC request with the full set of + * cross-cutting headers and `_meta`. Handles both JSON and SSE responses and + * (by default) retries once with a mutually supported version when the server + * rejects the advertised protocol version. + */ +export async function sendDraftRequest( + serverUrl: string, + method: string, + params?: Record, + options: DraftRequestOptions = {} +): Promise { + const id = options.id ?? nextRequestId++; + const response = await sendOnce(serverUrl, method, params, options, id); + + if (options.retryOnUnsupportedVersion === false) { + return response; + } + const supported = isUnsupportedVersionRejection( + response.status, + response.body + ); + if (!supported) { + return response; + } + const requested = options.protocolVersion ?? DRAFT_PROTOCOL_VERSION; + const retryVersion = supported.includes(requested) + ? requested + : supported.includes(DRAFT_PROTOCOL_VERSION) + ? DRAFT_PROTOCOL_VERSION + : supported[0]; + return sendOnce( + serverUrl, + method, + params, + { ...options, protocolVersion: retryVersion }, + id + ); +} + +async function sendOnce( + serverUrl: string, + method: string, + params: Record | undefined, + options: DraftRequestOptions, + id: number | string +): Promise { + const headers = buildDraftHeaders(method, params, options); + const enrichedParams = buildDraftParams(params, options); + const body = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + ...(enrichedParams !== undefined ? { params: enrichedParams } : {}) + }); + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + options.timeoutMs ?? 10000 + ); + try { + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body, + signal: controller.signal + }); + + const contentType = res.headers.get('content-type') ?? undefined; + const text = await res.text(); + + if (contentType?.includes('text/event-stream')) { + const events = parseSseEvents(text); + const match = events.find( + (e): e is JsonRpcResponse => + typeof e === 'object' && + e !== null && + (e as JsonRpcResponse).id === id && + ('result' in e || 'error' in e) + ); + const last = events.length > 0 ? events[events.length - 1] : undefined; + return { + status: res.status, + headers: res.headers, + contentType, + events, + body: (match ?? last) as JsonRpcResponse | undefined + }; + } + + try { + return { + status: res.status, + headers: res.headers, + contentType, + body: text ? (JSON.parse(text) as JsonRpcResponse) : undefined + }; + } catch { + return { status: res.status, headers: res.headers, contentType, text }; + } + } finally { + clearTimeout(timeout); + } +} diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index 528b958d..dc3f28fa 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -20,7 +20,7 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { connectToServer } from './client-helper'; +import { buildDraftParams, sendDraftRequest } from './draft-client'; const SPEC_REFERENCE = { id: 'SEP-2243-Server-Validation', @@ -264,51 +264,18 @@ export class HttpHeaderValidationScenario implements ClientScenario { async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; - let sessionId: string | null = null; try { - // Establish a session via normal SDK initialization - const connection = await connectToServer(serverUrl); - const toolsResult = await connection.client.listTools(); - await connection.close(); - - // Get a fresh session for raw requests - const initResponse = await sendRawRequest( - serverUrl, - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: DRAFT_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: 'conformance-test-raw-client', - version: '1.0.0' - } - } - }, - { 'Mcp-Method': 'initialize' } - ); - - if (initResponse.status === 200) { - const rawSid = initResponse.headers['mcp-session-id']; - sessionId = (Array.isArray(rawSid) ? rawSid[0] : rawSid) || null; - const notifHeaders: Record = { - 'Mcp-Method': 'notifications/initialized' - }; - if (sessionId) notifHeaders['mcp-session-id'] = sessionId; - await sendRawRequest( - serverUrl, - { jsonrpc: '2.0', method: 'notifications/initialized' }, - notifHeaders - ); - } + // Discover the server's tools with a fully-conformant stateless draft + // request — the draft protocol has no initialize handshake or sessions. + const toolsResponse = await sendDraftRequest(serverUrl, 'tools/list'); + const toolsResult = (toolsResponse.body?.result ?? {}) as { + tools?: Array<{ name: string; inputSchema?: unknown }>; + }; const baseHeaders: Record = { 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; - if (sessionId) baseHeaders['mcp-session-id'] = sessionId; let idCounter = 100; const nextId = () => idCounter++; @@ -501,7 +468,13 @@ export class HttpHeaderValidationScenario implements ClientScenario { details: Record ): Promise { try { - const requestBody = { ...body, id: body.id === 0 ? nextId() : body.id }; + // Issue #311: every raw request carries the SEP-2575 _meta fields — the + // header-validation cases only mangle headers, never the body metadata. + const requestBody = { + ...body, + id: body.id === 0 ? nextId() : body.id, + params: buildDraftParams(body.params, {}) + }; const response = await sendRawRequest(serverUrl, requestBody, { ...baseHeaders, ...extraHeaders @@ -565,12 +538,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; - let sessionId: string | null = null; try { - const connection = await connectToServer(serverUrl); - const toolsResult = await connection.client.listTools(); - await connection.close(); + // Discover the server's tools with a fully-conformant stateless draft + // request — the draft protocol has no initialize handshake or sessions. + const toolsResponse = await sendDraftRequest(serverUrl, 'tools/list'); + const toolsResult = (toolsResponse.body?.result ?? {}) as { + tools?: Array<{ name: string; inputSchema?: unknown }>; + }; // Find a tool with x-mcp-header annotations const xMcpTool = toolsResult.tools?.find((tool) => { @@ -602,43 +577,9 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario return checks; } - // Get a fresh session for raw requests - const initResponse = await sendRawRequest( - serverUrl, - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: DRAFT_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: 'conformance-test-base64-client', - version: '1.0.0' - } - } - }, - { 'Mcp-Method': 'initialize' } - ); - - if (initResponse.status === 200) { - const rawSid2 = initResponse.headers['mcp-session-id']; - sessionId = (Array.isArray(rawSid2) ? rawSid2[0] : rawSid2) || null; - const notifHeaders: Record = { - 'Mcp-Method': 'notifications/initialized' - }; - if (sessionId) notifHeaders['mcp-session-id'] = sessionId; - await sendRawRequest( - serverUrl, - { jsonrpc: '2.0', method: 'notifications/initialized' }, - notifHeaders - ); - } - const baseHeaders: Record = { 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; - if (sessionId) baseHeaders['mcp-session-id'] = sessionId; // Find the first x-mcp-header annotated STRING property // that is callable with minimal arguments to avoid schema validation failures @@ -890,10 +831,15 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario jsonrpc: '2.0', id: nextId(), method: 'tools/call', - params: { - name: toolName, - arguments: { ...defaultArgs, [paramName]: bodyValue } - } + // Issue #311: the body always carries the SEP-2575 _meta fields — + // these cases only vary the Mcp-Param header value. + params: buildDraftParams( + { + name: toolName, + arguments: { ...defaultArgs, [paramName]: bodyValue } + }, + {} + ) }, { ...baseHeaders, @@ -974,10 +920,15 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario jsonrpc: '2.0', id: nextId(), method: 'tools/call', - params: { - name: toolName, - arguments: { ...defaultArgs, [paramName]: 'test-value' } - } + // Issue #311: the body always carries the SEP-2575 _meta fields — + // this case only omits the Mcp-Param header. + params: buildDraftParams( + { + name: toolName, + arguments: { ...defaultArgs, [paramName]: 'test-value' } + }, + {} + ) }, { ...baseHeaders, diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts index a5b73ab7..742fb673 100644 --- a/src/scenarios/server/input-required-result-helpers.ts +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -5,8 +5,6 @@ * and a stateless JSON-RPC transport helper. */ -import { DRAFT_PROTOCOL_VERSION } from '../../types'; - // ─── JSON-RPC Types ────────────────────────────────────────────────────────── export interface JsonRpcResponse { @@ -18,47 +16,26 @@ export interface JsonRpcResponse { // ─── Stateless RPC Helper ──────────────────────────────────────────────────── -let nextId = 1; +import { sendDraftRequest } from './draft-client'; /** * Send a stateless JSON-RPC request (SEP-2575 pattern). - * Automatically injects _meta with protocolVersion, clientInfo, clientCapabilities. + * The shared draft helper injects the cross-cutting requirements: _meta + * (protocolVersion, clientInfo, clientCapabilities) and the standard + * MCP-Protocol-Version / Mcp-Method / Mcp-Name headers (SEP-2243). */ export async function sendRpc( serverUrl: string, method: string, params?: Record ): Promise { - const id = nextId++; - - const enrichedParams = { - ...params, - _meta: { - 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, - 'io.modelcontextprotocol/clientInfo': { - name: 'conformance-test-client', - version: '1.0.0' - }, - 'io.modelcontextprotocol/clientCapabilities': { - sampling: {}, - elicitation: {}, - roots: { listChanged: true } - }, - ...(params?._meta as Record | undefined) - } - }; - - const response = await fetch(serverUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION - }, - body: JSON.stringify({ jsonrpc: '2.0', id, method, params: enrichedParams }) - }); - - return (await response.json()) as JsonRpcResponse; + const response = await sendDraftRequest(serverUrl, method, params); + if (!response.body) { + throw new Error( + `Expected a JSON-RPC response for ${method}, got HTTP ${response.status} (${response.contentType ?? 'no content-type'})` + ); + } + return response.body as JsonRpcResponse; } // ─── InputRequiredResult Types ─────────────────────────────────────────────── diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index be332d49..16e18705 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -8,10 +8,10 @@ import { DRAFT_PROTOCOL_VERSION } from '../../types'; import { connectToServer } from './client-helper'; +import { sendDraftRequest } from './draft-client'; import { TextResourceContents, - BlobResourceContents, - McpError + BlobResourceContents } from '@modelcontextprotocol/sdk/types.js'; export class ResourcesListScenario implements ClientScenario { @@ -484,9 +484,13 @@ This scenario does not require the server to register any specific resource — } ]; - let connection; + // SEP-2164 is a draft-spec requirement, so the request is sent statelessly + // with the draft protocol version and the cross-cutting _meta/headers. + let response; try { - connection = await connectToServer(serverUrl); + response = await sendDraftRequest(serverUrl, 'resources/read', { + uri: nonexistentUri + }); } catch (error) { checks.push({ id: 'sep-2164-error-code', @@ -495,19 +499,16 @@ This scenario does not require the server to register any specific resource — 'Server returns -32602 (Invalid Params) for non-existent resource', status: 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`, + errorMessage: `resources/read request failed: ${error instanceof Error ? error.message : String(error)}`, specReferences }); return checks; } - let caughtError: unknown; - let result: { contents: unknown[] } | undefined; - try { - result = await connection.client.readResource({ uri: nonexistentUri }); - } catch (error) { - caughtError = error; - } + const result = response.body?.result as + | { contents?: unknown[] } + | undefined; + const rpcError = response.body?.error; // Check 1: MUST NOT return an empty contents array const returnedEmptyContents = @@ -531,13 +532,12 @@ This scenario does not require the server to register any specific resource — }); // Check 2: SHOULD return JSON-RPC error with code -32602 - const errorCode = - caughtError instanceof McpError ? caughtError.code : undefined; + const errorCode = rpcError?.code; let errorCodeMessage: string | undefined; if (result !== undefined) { errorCodeMessage = `Server returned a result instead of an error (contents length: ${result.contents?.length ?? 'undefined'}). Servers SHOULD return a JSON-RPC error for non-existent resources.`; - } else if (!(caughtError instanceof McpError)) { - errorCodeMessage = `Expected a JSON-RPC error, got: ${caughtError instanceof Error ? caughtError.message : String(caughtError)}`; + } else if (!rpcError) { + errorCodeMessage = `Expected a JSON-RPC error, got HTTP ${response.status} with no JSON-RPC error in the body.`; } else if (errorCode !== -32602) { errorCodeMessage = `Expected error code -32602 (Invalid Params), got ${errorCode}. ` + @@ -563,10 +563,7 @@ This scenario does not require the server to register any specific resource — }); // Check 3: SHOULD include uri in error data field - const errorData = - caughtError instanceof McpError - ? (caughtError.data as { uri?: string } | undefined) - : undefined; + const errorData = rpcError?.data as { uri?: string } | undefined; const dataUriMatches = errorData?.uri === nonexistentUri; checks.push({ @@ -574,19 +571,13 @@ This scenario does not require the server to register any specific resource — name: 'ResourcesNotFoundDataUri', description: 'Server includes the requested URI in the error data field (SHOULD)', - status: - caughtError instanceof McpError - ? dataUriMatches - ? 'SUCCESS' - : 'WARNING' - : 'FAILURE', + status: rpcError ? (dataUriMatches ? 'SUCCESS' : 'WARNING') : 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: - caughtError instanceof McpError - ? dataUriMatches - ? undefined - : `Error data.uri is ${JSON.stringify(errorData?.uri)}, expected "${nonexistentUri}". This is a SHOULD requirement.` - : 'No JSON-RPC error received; cannot evaluate data field.', + errorMessage: rpcError + ? dataUriMatches + ? undefined + : `Error data.uri is ${JSON.stringify(errorData?.uri)}, expected "${nonexistentUri}". This is a SHOULD requirement.` + : 'No JSON-RPC error received; cannot evaluate data field.', specReferences, details: { requestedUri: nonexistentUri, @@ -594,7 +585,6 @@ This scenario does not require the server to register any specific resource — } }); - await connection.close(); return checks; } } diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index def16e48..486db4d6 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -7,6 +7,7 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; +import { buildDraftHeaders } from './draft-client'; const SPEC_REF = [ { @@ -115,12 +116,13 @@ export class ServerStatelessScenario implements ClientScenario { headersOverrides?: Record, id: string | number | null = 1 ) => { - const headers = { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION, - ...headersOverrides - }; + // The cross-cutting SEP-2243 headers (Mcp-Method, Mcp-Name, Accept, + // MCP-Protocol-Version) are not what this scenario exercises, so they + // are always sent conformantly; overrides only alter the dimension a + // test case is about (issue #312). + const headers = buildDraftHeaders(method, params, { + headers: headersOverrides + }); const body = JSON.stringify({ jsonrpc: '2.0', @@ -150,11 +152,7 @@ export class ServerStatelessScenario implements ClientScenario { timeoutMs = 1000, onFirstFrame?: () => Promise ): Promise => { - const headers = { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION - }; + const headers = buildDraftHeaders(method, params); const body = JSON.stringify({ jsonrpc: '2.0', From 2b418c1c26784045e57f6444e909f66c3cc97e03 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 26 May 2026 13:28:02 +0100 Subject: [PATCH 3/4] refactor: rename the stateless request helpers and harden them per review - rename draft-client.ts to stateless-client.ts and name the helpers by feature (sendStatelessRequest, buildStandardHeaders, buildRequestMeta, withRequestMeta, CONFORMANCE_CLIENT_INFO); 'draft' now only appears in DRAFT_PROTOCOL_VERSION, the one name that tracks the moving target - retry only genuine UnsupportedProtocolVersionError rejections (-32004, tolerating -32001/-32602) and only with recognized versions; record the retry on the response - parse text/event-stream responses incrementally and resolve on the matching event instead of blocking until the timeout - keep the MCP-Protocol-Version header and _meta protocolVersion in sync when the version is overridden via meta overrides - make server-stateless's sendRpc SSE-tolerant - emit explicit setup failures in the SEP-2243 server scenarios when the tools/list discovery fails instead of misleading per-case results - latch the worst status per check in the request-metadata judge so an early violation cannot be masked by a later conformant request - rename the gate to harness-traffic-conformance.test.ts and derive its driver list from the draft suite with documented per-judge exclusions (35 pairings) - everything-server: advertise resources in stateless server/discover and use -32004 for unsupported protocol version - add unit tests for the helper (retry guard, SSE handling, header/_meta version sync, default headers) --- .../servers/typescript/everything-server.ts | 11 +- .../typescript/sep-2164-empty-contents.ts | 2 +- .../typescript/sep-2549-no-caching-hints.ts | 2 +- src/scenarios/client/request-metadata.ts | 27 +- ...ts => harness-traffic-conformance.test.ts} | 75 +-- src/scenarios/server/caching.ts | 10 +- src/scenarios/server/draft-client.ts | 340 ------------ src/scenarios/server/http-standard-headers.ts | 80 ++- .../server/input-required-result-helpers.ts | 13 +- src/scenarios/server/resources.ts | 4 +- src/scenarios/server/stateless-client.test.ts | 252 +++++++++ src/scenarios/server/stateless-client.ts | 503 ++++++++++++++++++ src/scenarios/server/stateless.ts | 29 +- 13 files changed, 931 insertions(+), 417 deletions(-) rename src/scenarios/{draft-self-conformance.test.ts => harness-traffic-conformance.test.ts} (50%) delete mode 100644 src/scenarios/server/draft-client.ts create mode 100644 src/scenarios/server/stateless-client.test.ts create mode 100644 src/scenarios/server/stateless-client.ts diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 20a5ac85..258fe3d6 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1227,13 +1227,13 @@ app.post('/mcp', async (req, res) => { }); } - // Protocol Version Negotiation Matrix (-32602, HTTP 400) + // Protocol Version Negotiation Matrix (-32004, HTTP 400) if (metaVersion !== 'DRAFT-2026-v1') { return res.status(400).json({ jsonrpc: '2.0', id, error: { - code: -32602, + code: -32004, message: 'UnsupportedProtocolVersionError', data: { supported: ['DRAFT-2026-v1'] } } @@ -1296,7 +1296,10 @@ app.post('/mcp', async (req, res) => { supportedVersions: ['DRAFT-2026-v1'], capabilities: { tools: { listChanged: true }, // Explicitly announce dynamic capabilities matching Section 7 expectations - prompts: { listChanged: true } + prompts: { listChanged: true }, + // resources/list, resources/templates/list and resources/read are + // served on this path, so the capability must be declared too. + resources: {} }, serverInfo: { name: 'everything-stateless-server', version: '1.0.0' } } @@ -1447,7 +1450,7 @@ app.post('/mcp', async (req, res) => { } } - // Resources on the stateless draft path (SEP-2549 hints + SEP-2164 errors). + // Resources on the stateless path (SEP-2575): SEP-2549 hints + SEP-2164 errors. if (method === 'resources/list') { return res.json({ jsonrpc: '2.0', diff --git a/examples/servers/typescript/sep-2164-empty-contents.ts b/examples/servers/typescript/sep-2164-empty-contents.ts index ffb28854..570b4567 100644 --- a/examples/servers/typescript/sep-2164-empty-contents.ts +++ b/examples/servers/typescript/sep-2164-empty-contents.ts @@ -3,7 +3,7 @@ /** * SEP-2164 Negative Test Server * - * Speaks the stateless draft wire protocol (SEP-2575) but returns an empty + * Speaks the stateless wire protocol (SEP-2575) but returns an empty * contents array for any resources/read request, violating the SEP-2164 MUST * NOT. The sep-2164-resource-not-found scenario should emit FAILURE for * sep-2164-no-empty-contents against this server. diff --git a/examples/servers/typescript/sep-2549-no-caching-hints.ts b/examples/servers/typescript/sep-2549-no-caching-hints.ts index 78c861b8..318c9fd3 100644 --- a/examples/servers/typescript/sep-2549-no-caching-hints.ts +++ b/examples/servers/typescript/sep-2549-no-caching-hints.ts @@ -3,7 +3,7 @@ /** * SEP-2549 Negative Test Server * - * Speaks the stateless draft wire protocol (SEP-2575) but returns list and + * Speaks the stateless wire protocol (SEP-2575) but returns list and * read results WITHOUT ttlMs and cacheScope fields, violating the SEP-2549 * MUST. The caching scenario should emit FAILURE for presence checks against * this server. diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 0fee696f..9d9fd69d 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -3,9 +3,23 @@ import { Scenario, ScenarioUrls, ConformanceCheck, + CheckStatus, DRAFT_PROTOCOL_VERSION } from '../../types'; +/** + * Severity ranking used to latch per-id check results: a single + * non-conformant request is a violation even if later requests are + * conformant, so a later better status must never overwrite a worse one. + */ +const STATUS_SEVERITY: Record = { + FAILURE: 3, + WARNING: 2, + SUCCESS: 1, + INFO: 1, + SKIPPED: 0 +}; + /** * Every check ID this scenario can emit. Declared-but-unemitted checks are * backfilled as FAILURE by getChecks(), so the emitted ID set is the same for @@ -92,10 +106,17 @@ export class RequestMetadataScenario implements Scenario { private addOrUpdateCheck(check: ConformanceCheck): void { const index = this.checks.findIndex((c) => c.id === check.id); - if (index !== -1) { - this.checks[index] = check; - } else { + if (index === -1) { this.checks.push(check); + return; + } + // Keep the worst status observed for this id (FAILURE > WARNING > SUCCESS + // > SKIPPED): an equal-or-worse result replaces the stored check (so its + // details stay fresh), but a better result must not erase a violation + // recorded from an earlier request. + const existing = this.checks[index]; + if (STATUS_SEVERITY[check.status] >= STATUS_SEVERITY[existing.status]) { + this.checks[index] = check; } } diff --git a/src/scenarios/draft-self-conformance.test.ts b/src/scenarios/harness-traffic-conformance.test.ts similarity index 50% rename from src/scenarios/draft-self-conformance.test.ts rename to src/scenarios/harness-traffic-conformance.test.ts index 125c495c..532c97e8 100644 --- a/src/scenarios/draft-self-conformance.test.ts +++ b/src/scenarios/harness-traffic-conformance.test.ts @@ -1,35 +1,30 @@ /** - * Draft self-conformance gate: "test conformance with conformance". + * Harness traffic conformance gate: "test conformance with conformance". * - * The harness's own draft-spec server-scenario drivers (the requests we send - * when testing an SDK server) must satisfy the cross-cutting draft client - * obligations — otherwise a strictly-conformant SDK rejects harness traffic - * for reasons unrelated to the behaviour under test (issues #311, #312, #315). + * The harness's own stateless server-scenario drivers (the requests we send + * when testing an SDK server) must satisfy the cross-cutting client + * obligations from SEP-2575 and SEP-2243 — otherwise a strictly-conformant SDK + * rejects harness traffic for reasons unrelated to the behaviour under test + * (issues #311, #312, #315). * * Rather than writing bespoke assertions, each pairing starts an existing * client-testing Scenario as the judge (a mock server that inspects every - * incoming request and emits conformance checks) and points a draft + * incoming request and emits conformance checks) and points a stateless * ClientScenario driver at it. The driver's own checks are irrelevant here — * the judge's checks are the assertion. */ import { describe, it, expect, afterEach } from 'vitest'; -import { getClientScenario } from './index'; +import { getClientScenario, listDraftClientScenarios } from './index'; import { HttpStandardHeadersScenario } from './client/http-standard-headers'; import { RequestMetadataScenario } from './client/request-metadata'; import type { Scenario } from '../types'; /** - * judge scenario -> driver scenarios. + * judge scenario -> factory. * * Judges are instantiated fresh for every pairing (the registry instances are * module-level singletons whose recorded checks would otherwise leak between * pairings). - * - * Only positive draft drivers are paired with a given judge: scenarios that - * deliberately send traffic violating that judge's dimension (e.g. - * server-stateless's invalid-_meta cases against the SEP-2575 judge, or - * http-header-validation's mangled headers against the SEP-2243 judge) are - * excluded from that judge's row. */ const JUDGES: Record Scenario> = { // SEP-2243: Mcp-Method / Mcp-Name headers on every request @@ -38,21 +33,30 @@ const JUDGES: Record Scenario> = { 'request-metadata': () => new RequestMetadataScenario() }; -const PAIRINGS: Record = { - 'http-standard-headers': [ - 'caching', - 'input-required-result-basic-elicitation', - 'sep-2164-resource-not-found', +/** + * Drivers excluded per judge: only scenarios that deliberately send traffic + * violating that judge's dimension are excluded. Everything else that targets + * the draft spec is paired automatically (derived from the scenario registry, + * not hand-listed). + */ +const EXCLUSIONS: Record> = { + 'http-standard-headers': new Set([ + // Deliberately sends mismatched/missing Mcp-Method and Mcp-Name headers. + 'http-header-validation', + // Deliberately sends mismatched/missing Mcp-Name and Mcp-Param-* headers. + 'http-custom-header-server-validation' + ]), + 'request-metadata': new Set([ + // Deliberately sends requests with missing/invalid _meta and mismatched + // protocol versions. 'server-stateless' - ], - 'request-metadata': [ - 'caching', - 'input-required-result-basic-elicitation', - 'sep-2164-resource-not-found' - ] + // http-header-validation / http-custom-header-server-validation only + // mangle the SEP-2243 Mcp-* headers; their MCP-Protocol-Version header and + // _meta stay conformant, so they are judged here. + ]) }; -describe('draft self-conformance (harness traffic judged by client scenarios)', () => { +describe('harness traffic conformance (drivers judged by client scenarios)', () => { let judge: Scenario | undefined; afterEach(async () => { @@ -62,30 +66,35 @@ describe('draft self-conformance (harness traffic judged by client scenarios)', } }); - for (const [judgeName, driverNames] of Object.entries(PAIRINGS)) { + for (const [judgeName, makeJudge] of Object.entries(JUDGES)) { + const driverNames = listDraftClientScenarios().filter( + (name) => !EXCLUSIONS[judgeName].has(name) + ); + for (const driverName of driverNames) { it(`${driverName} traffic passes the ${judgeName} checks`, async () => { - judge = JUDGES[judgeName](); - expect(judge, `judge scenario ${judgeName} not found`).toBeDefined(); + judge = makeJudge(); const driver = getClientScenario(driverName); expect(driver, `driver scenario ${driverName} not found`).toBeDefined(); - const urls = await judge!.start(); + const urls = await judge.start(); try { // The judge's mock is not a real MCP server, so the driver's own // checks routinely fail against it — that is expected and ignored. // Only the traffic the driver emitted matters here. await driver!.run(urls.serverUrl).catch(() => {}); } finally { - await judge!.stop(); + await judge.stop(); } - const verdicts = judge! + // WARNING also fails the gate on purpose: SHOULD-level obligations are + // mandatory for the harness's own traffic. + const verdicts = judge .getChecks() .filter((c) => c.status === 'FAILURE' || c.status === 'WARNING'); expect( verdicts, - `harness traffic from "${driverName}" violated draft client obligations:\n` + + `harness traffic from "${driverName}" violated client obligations:\n` + verdicts .map( (c) => diff --git a/src/scenarios/server/caching.ts b/src/scenarios/server/caching.ts index 53a20ff4..7b1bd22b 100644 --- a/src/scenarios/server/caching.ts +++ b/src/scenarios/server/caching.ts @@ -10,7 +10,7 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { sendDraftRequest } from './draft-client'; +import { sendStatelessRequest } from './stateless-client'; const SPEC_REFS = [ { @@ -92,7 +92,7 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p const allFields: Array<{ endpoint: string; fields: CachingFields }> = []; // SEP-2549 only exists in the draft spec, so each cacheable endpoint is - // queried with a stateless draft request: protocolVersion DRAFT-2026-v1 + // queried over the stateless path (SEP-2575): protocolVersion DRAFT-2026-v1 // plus the cross-cutting _meta and standard headers (issue #315). const queryEndpoint = async ( checkId: string, @@ -102,7 +102,11 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p ): Promise | undefined> => { const description = `${endpoint} response includes ttlMs and cacheScope caching hints`; try { - const response = await sendDraftRequest(serverUrl, endpoint, params); + const response = await sendStatelessRequest( + serverUrl, + endpoint, + params + ); const result = response.body?.result; if (!result) { const error = response.body?.error; diff --git a/src/scenarios/server/draft-client.ts b/src/scenarios/server/draft-client.ts deleted file mode 100644 index cec23432..00000000 --- a/src/scenarios/server/draft-client.ts +++ /dev/null @@ -1,340 +0,0 @@ -/** - * Stateless draft-spec request helpers for server scenarios. - * - * The draft spec makes the protocol stateless (SEP-2575) and standardizes the - * HTTP header layer (SEP-2243). Every request the harness sends to a server - * under test therefore has cross-cutting obligations that are independent of - * whatever a scenario is actually testing: - * - * - `MCP-Protocol-Version` header on every POST, matching - * `_meta["io.modelcontextprotocol/protocolVersion"]` in the body - * - `Mcp-Method` header mirroring the JSON-RPC `method` - * - `Mcp-Name` header mirroring `params.name` (tools/call, prompts/get) or - * `params.uri` (resources/read) - * - `_meta` carrying protocolVersion, clientInfo and clientCapabilities - * - an `Accept` header listing both `application/json` and `text/event-stream` - * - * Draft scenarios MUST build their requests through these helpers so a - * strictly-conformant server never rejects harness traffic for reasons - * unrelated to the behaviour under test (issues #311, #312, #315). Negative - * tests can override or omit exactly the dimension they exercise via the - * options. - * - * The harness's own conformance is enforced by - * `src/scenarios/draft-self-conformance.test.ts`. - */ - -import { DRAFT_PROTOCOL_VERSION } from '../../types'; - -// ─── JSON-RPC types ────────────────────────────────────────────────────────── - -export interface JsonRpcResponse { - jsonrpc: '2.0'; - id: number | string | null; - result?: Record; - error?: { code: number; message: string; data?: unknown }; -} - -// ─── Defaults ──────────────────────────────────────────────────────────────── - -export const DRAFT_CLIENT_INFO = { - name: 'conformance-test-client', - version: '1.0.0' -} as const; - -export const DRAFT_CLIENT_CAPABILITIES = { - sampling: {}, - elicitation: {}, - roots: { listChanged: true } -} as const; - -// ─── Options ───────────────────────────────────────────────────────────────── - -export interface DraftHeaderOptions { - /** Wire protocol version to advertise (header + _meta). */ - protocolVersion?: string; - /** Extra or overriding headers (later wins; case preserved as given). */ - headers?: Record; - /** Default header names to drop entirely (case-insensitive). */ - omitHeaders?: string[]; -} - -export interface DraftRequestOptions extends DraftHeaderOptions { - /** JSON-RPC id; auto-incremented when omitted. */ - id?: number | string; - /** - * Extra `_meta` keys merged over the conformant defaults, or `false` to - * omit `_meta` entirely (negative tests only). Keys already present in - * `params._meta` also override the defaults. - */ - meta?: Record | false; - /** Client capabilities advertised in `_meta`; defaults to all optional ones. */ - clientCapabilities?: Record; - /** - * Retry once with a server-supported version when the request is rejected - * as an unsupported protocol version (the spec SHOULD for clients). - * Defaults to true. - */ - retryOnUnsupportedVersion?: boolean; - /** Abort the request after this many milliseconds. Defaults to 10s. */ - timeoutMs?: number; -} - -export interface DraftResponse { - status: number; - headers: Headers; - contentType?: string; - /** - * The parsed JSON-RPC message: the JSON body, or — for `text/event-stream` - * responses — the event matching the request id (falling back to the last - * parsed event). - */ - body?: JsonRpcResponse; - /** All parsed events when the response was an SSE / chunked stream. */ - events?: unknown[]; - /** Raw response text when it could not be parsed as JSON. */ - text?: string; -} - -let nextRequestId = 1; - -// ─── Header construction ───────────────────────────────────────────────────── - -/** - * The `Mcp-Name` source field per SEP-2243: `params.name` for tools/call and - * prompts/get, `params.uri` for resources/read; absent otherwise. - */ -export function mcpNameForRequest( - method: string, - params?: Record -): string | undefined { - if (method === 'tools/call' || method === 'prompts/get') { - return typeof params?.name === 'string' ? params.name : undefined; - } - if (method === 'resources/read') { - return typeof params?.uri === 'string' ? params.uri : undefined; - } - return undefined; -} - -/** - * Build the conformant header set for a draft request: Content-Type, Accept - * (both content types), MCP-Protocol-Version, Mcp-Method and (when the method - * carries one) Mcp-Name. Overrides win over defaults; omitHeaders removes - * defaults entirely. - */ -export function buildDraftHeaders( - method: string, - params?: Record, - options: DraftHeaderOptions = {} -): Record { - const protocolVersion = options.protocolVersion ?? DRAFT_PROTOCOL_VERSION; - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'MCP-Protocol-Version': protocolVersion, - 'Mcp-Method': method - }; - const name = mcpNameForRequest(method, params); - if (name !== undefined) { - headers['Mcp-Name'] = name; - } - - if (options.omitHeaders) { - const omit = new Set(options.omitHeaders.map((h) => h.toLowerCase())); - for (const key of Object.keys(headers)) { - if (omit.has(key.toLowerCase())) delete headers[key]; - } - } - - if (options.headers) { - for (const [key, value] of Object.entries(options.headers)) { - // Replace any default that differs only by case, then set the override. - for (const existing of Object.keys(headers)) { - if (existing.toLowerCase() === key.toLowerCase()) { - delete headers[existing]; - } - } - headers[key] = value; - } - } - - return headers; -} - -// ─── Body construction ─────────────────────────────────────────────────────── - -/** Build the conformant `_meta` object required on every draft request. */ -export function buildDraftMeta( - overrides?: Record, - protocolVersion: string = DRAFT_PROTOCOL_VERSION, - clientCapabilities: Record = DRAFT_CLIENT_CAPABILITIES -): Record { - return { - 'io.modelcontextprotocol/protocolVersion': protocolVersion, - 'io.modelcontextprotocol/clientInfo': DRAFT_CLIENT_INFO, - 'io.modelcontextprotocol/clientCapabilities': clientCapabilities, - ...overrides - }; -} - -/** Merge params with the conformant `_meta` (or omit it when meta === false). */ -export function buildDraftParams( - params: Record | undefined, - options: DraftRequestOptions = {} -): Record | undefined { - if (options.meta === false) { - return params; - } - const protocolVersion = options.protocolVersion ?? DRAFT_PROTOCOL_VERSION; - return { - ...params, - _meta: buildDraftMeta( - { - ...(params?._meta as Record | undefined), - ...(options.meta ?? undefined) - }, - protocolVersion, - options.clientCapabilities ?? DRAFT_CLIENT_CAPABILITIES - ) - }; -} - -// ─── Response parsing ──────────────────────────────────────────────────────── - -function parseSseEvents(text: string): unknown[] { - const events: unknown[] = []; - for (const rawLine of text.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line) continue; - const jsonText = line.startsWith('data:') - ? line.replace(/^data:\s*/, '') - : line; - try { - events.push(JSON.parse(jsonText)); - } catch { - // Non-JSON line (comments, partial frames) — ignore. - } - } - return events; -} - -function isUnsupportedVersionRejection( - status: number, - body: JsonRpcResponse | undefined -): string[] | undefined { - if (status !== 400 || !body?.error) return undefined; - const data = body.error.data as { supported?: unknown } | undefined; - if (Array.isArray(data?.supported) && data.supported.length > 0) { - return data.supported.filter((v): v is string => typeof v === 'string'); - } - return undefined; -} - -// ─── Requests ──────────────────────────────────────────────────────────────── - -/** - * Send a single stateless draft JSON-RPC request with the full set of - * cross-cutting headers and `_meta`. Handles both JSON and SSE responses and - * (by default) retries once with a mutually supported version when the server - * rejects the advertised protocol version. - */ -export async function sendDraftRequest( - serverUrl: string, - method: string, - params?: Record, - options: DraftRequestOptions = {} -): Promise { - const id = options.id ?? nextRequestId++; - const response = await sendOnce(serverUrl, method, params, options, id); - - if (options.retryOnUnsupportedVersion === false) { - return response; - } - const supported = isUnsupportedVersionRejection( - response.status, - response.body - ); - if (!supported) { - return response; - } - const requested = options.protocolVersion ?? DRAFT_PROTOCOL_VERSION; - const retryVersion = supported.includes(requested) - ? requested - : supported.includes(DRAFT_PROTOCOL_VERSION) - ? DRAFT_PROTOCOL_VERSION - : supported[0]; - return sendOnce( - serverUrl, - method, - params, - { ...options, protocolVersion: retryVersion }, - id - ); -} - -async function sendOnce( - serverUrl: string, - method: string, - params: Record | undefined, - options: DraftRequestOptions, - id: number | string -): Promise { - const headers = buildDraftHeaders(method, params, options); - const enrichedParams = buildDraftParams(params, options); - const body = JSON.stringify({ - jsonrpc: '2.0', - id, - method, - ...(enrichedParams !== undefined ? { params: enrichedParams } : {}) - }); - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - options.timeoutMs ?? 10000 - ); - try { - const res = await fetch(serverUrl, { - method: 'POST', - headers, - body, - signal: controller.signal - }); - - const contentType = res.headers.get('content-type') ?? undefined; - const text = await res.text(); - - if (contentType?.includes('text/event-stream')) { - const events = parseSseEvents(text); - const match = events.find( - (e): e is JsonRpcResponse => - typeof e === 'object' && - e !== null && - (e as JsonRpcResponse).id === id && - ('result' in e || 'error' in e) - ); - const last = events.length > 0 ? events[events.length - 1] : undefined; - return { - status: res.status, - headers: res.headers, - contentType, - events, - body: (match ?? last) as JsonRpcResponse | undefined - }; - } - - try { - return { - status: res.status, - headers: res.headers, - contentType, - body: text ? (JSON.parse(text) as JsonRpcResponse) : undefined - }; - } catch { - return { status: res.status, headers: res.headers, contentType, text }; - } - } finally { - clearTimeout(timeout); - } -} diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index dc3f28fa..bf490f0f 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -20,7 +20,7 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { buildDraftParams, sendDraftRequest } from './draft-client'; +import { withRequestMeta, sendStatelessRequest } from './stateless-client'; const SPEC_REFERENCE = { id: 'SEP-2243-Server-Validation', @@ -266,10 +266,31 @@ export class HttpHeaderValidationScenario implements ClientScenario { const checks: ConformanceCheck[] = []; try { - // Discover the server's tools with a fully-conformant stateless draft - // request — the draft protocol has no initialize handshake or sessions. - const toolsResponse = await sendDraftRequest(serverUrl, 'tools/list'); - const toolsResult = (toolsResponse.body?.result ?? {}) as { + // Discover the server's tools with a fully-conformant stateless request + // (SEP-2575) — that wire protocol has no initialize handshake or sessions. + const toolsResponse = await sendStatelessRequest(serverUrl, 'tools/list'); + if (!toolsResponse.body?.result) { + // The server under test could not even answer a conformant tools/list: + // report a single explicit setup failure instead of misleading + // per-case results against a broken server. + const rpcError = toolsResponse.body?.error; + checks.push({ + id: 'sep-2243-server-standard-setup', + name: 'HttpHeaderValidationSetup', + description: 'Setup for header validation tests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + `tools/list discovery failed: HTTP ${toolsResponse.status}` + + (rpcError + ? `, JSON-RPC error ${rpcError.code}: ${rpcError.message}` + : ', no result in response body'), + specReferences: [SPEC_REFERENCE], + details: { httpStatus: toolsResponse.status, error: rpcError } + }); + return checks; + } + const toolsResult = toolsResponse.body.result as { tools?: Array<{ name: string; inputSchema?: unknown }>; }; @@ -473,7 +494,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { const requestBody = { ...body, id: body.id === 0 ? nextId() : body.id, - params: buildDraftParams(body.params, {}) + params: withRequestMeta(body.params, {}) }; const response = await sendRawRequest(serverUrl, requestBody, { ...baseHeaders, @@ -540,10 +561,33 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario const checks: ConformanceCheck[] = []; try { - // Discover the server's tools with a fully-conformant stateless draft - // request — the draft protocol has no initialize handshake or sessions. - const toolsResponse = await sendDraftRequest(serverUrl, 'tools/list'); - const toolsResult = (toolsResponse.body?.result ?? {}) as { + // Discover the server's tools with a fully-conformant stateless request + // (SEP-2575) — that wire protocol has no initialize handshake or sessions. + const toolsResponse = await sendStatelessRequest(serverUrl, 'tools/list'); + if (!toolsResponse.body?.result) { + // The server under test could not even answer a conformant tools/list: + // report a single explicit setup failure (and backfill the declared + // checks, mirroring the catch path) instead of pretending the + // requirements are not applicable to a broken server. + const rpcError = toolsResponse.body?.error; + checks.push({ + id: 'sep-2243-server-custom-setup', + name: 'HttpCustomHeaderServerValidationSetup', + description: 'Setup for custom header server validation tests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + `tools/list discovery failed: HTTP ${toolsResponse.status}` + + (rpcError + ? `, JSON-RPC error ${rpcError.code}: ${rpcError.message}` + : ', no result in response body'), + specReferences: [SPEC_REFERENCE_CUSTOM], + details: { httpStatus: toolsResponse.status, error: rpcError } + }); + this.failDeclaredChecks(checks); + return checks; + } + const toolsResult = toolsResponse.body.result as { tools?: Array<{ name: string; inputSchema?: unknown }>; }; @@ -769,6 +813,16 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // Declared-but-unemitted -> FAILURE. Reached only when setup threw partway // through (the gate-out paths emit SKIPPED rows and the happy path emits // every declared ID). + this.failDeclaredChecks(checks); + + return checks; + } + + /** + * Backfill every declared-but-unemitted check ID as FAILURE when setup + * failed before the cases could run, keeping the emitted ID set stable. + */ + private failDeclaredChecks(checks: ConformanceCheck[]): void { for (const id of CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS) { if (checks.some((c) => c.id === id)) continue; checks.push({ @@ -782,8 +836,6 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario specReferences: [SPEC_REFERENCE_CUSTOM] }); } - - return checks; } /** @@ -833,7 +885,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario method: 'tools/call', // Issue #311: the body always carries the SEP-2575 _meta fields — // these cases only vary the Mcp-Param header value. - params: buildDraftParams( + params: withRequestMeta( { name: toolName, arguments: { ...defaultArgs, [paramName]: bodyValue } @@ -922,7 +974,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario method: 'tools/call', // Issue #311: the body always carries the SEP-2575 _meta fields — // this case only omits the Mcp-Param header. - params: buildDraftParams( + params: withRequestMeta( { name: toolName, arguments: { ...defaultArgs, [paramName]: 'test-value' } diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts index 742fb673..689807ce 100644 --- a/src/scenarios/server/input-required-result-helpers.ts +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -7,20 +7,15 @@ // ─── JSON-RPC Types ────────────────────────────────────────────────────────── -export interface JsonRpcResponse { - jsonrpc: '2.0'; - id: number; - result?: Record; - error?: { code: number; message: string; data?: unknown }; -} +export type { JsonRpcResponse } from './stateless-client'; // ─── Stateless RPC Helper ──────────────────────────────────────────────────── -import { sendDraftRequest } from './draft-client'; +import { sendStatelessRequest, JsonRpcResponse } from './stateless-client'; /** * Send a stateless JSON-RPC request (SEP-2575 pattern). - * The shared draft helper injects the cross-cutting requirements: _meta + * The shared stateless helper injects the cross-cutting requirements: _meta * (protocolVersion, clientInfo, clientCapabilities) and the standard * MCP-Protocol-Version / Mcp-Method / Mcp-Name headers (SEP-2243). */ @@ -29,7 +24,7 @@ export async function sendRpc( method: string, params?: Record ): Promise { - const response = await sendDraftRequest(serverUrl, method, params); + const response = await sendStatelessRequest(serverUrl, method, params); if (!response.body) { throw new Error( `Expected a JSON-RPC response for ${method}, got HTTP ${response.status} (${response.contentType ?? 'no content-type'})` diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index 16e18705..36f56600 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -8,7 +8,7 @@ import { DRAFT_PROTOCOL_VERSION } from '../../types'; import { connectToServer } from './client-helper'; -import { sendDraftRequest } from './draft-client'; +import { sendStatelessRequest } from './stateless-client'; import { TextResourceContents, BlobResourceContents @@ -488,7 +488,7 @@ This scenario does not require the server to register any specific resource — // with the draft protocol version and the cross-cutting _meta/headers. let response; try { - response = await sendDraftRequest(serverUrl, 'resources/read', { + response = await sendStatelessRequest(serverUrl, 'resources/read', { uri: nonexistentUri }); } catch (error) { diff --git a/src/scenarios/server/stateless-client.test.ts b/src/scenarios/server/stateless-client.test.ts new file mode 100644 index 00000000..6998e8be --- /dev/null +++ b/src/scenarios/server/stateless-client.test.ts @@ -0,0 +1,252 @@ +/** + * Unit tests for the shared stateless request helper (SEP-2575 + SEP-2243): + * unsupported-version retry guard, SSE response handling, header/_meta + * protocol-version sync, and standard header defaults/omission. + */ +import http from 'http'; +import { describe, test, expect, afterEach } from 'vitest'; +import { sendStatelessRequest } from './stateless-client'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; + +interface RecordedRequest { + headers: http.IncomingHttpHeaders; + body: any; +} + +interface StubServer { + url: string; + requests: RecordedRequest[]; + close: () => Promise; +} + +function startStubServer( + handler: ( + recorded: RecordedRequest, + requestIndex: number, + res: http.ServerResponse + ) => void +): Promise { + const requests: RecordedRequest[] = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const recorded: RecordedRequest = { + headers: req.headers, + body: JSON.parse(body) + }; + requests.push(recorded); + handler(recorded, requests.length, res); + }); + }); + return new Promise((resolve) => { + server.listen(0, () => { + const port = (server.address() as { port: number }).port; + resolve({ + url: `http://localhost:${port}/`, + requests, + close: () => + new Promise((resolveClose) => { + // Tear down any SSE connections deliberately left open by a test. + server.closeAllConnections(); + server.close(() => resolveClose()); + }) + }); + }); + }); +} + +function respondJson( + res: http.ServerResponse, + status: number, + body: unknown +): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +describe('sendStatelessRequest', () => { + let stub: StubServer | undefined; + + afterEach(async () => { + if (stub) { + await stub.close(); + stub = undefined; + } + }); + + test('does not retry a 400 whose error code is not a version rejection', async () => { + stub = await startStubServer((recorded, _index, res) => { + // 400 with an unrelated error code whose data happens to carry an array + // named "supported" — must NOT be treated as a version rejection. + respondJson(res, 400, { + jsonrpc: '2.0', + id: recorded.body.id, + error: { + code: -32099, + message: 'scope rejected', + data: { supported: ['something-unrelated'] } + } + }); + }); + + const response = await sendStatelessRequest(stub.url, 'tools/list'); + + expect(stub.requests).toHaveLength(1); + expect(response.status).toBe(400); + expect(response.body?.error?.code).toBe(-32099); + expect(response.body?.error?.message).toBe('scope rejected'); + expect(response.versionRetry).toBeUndefined(); + }); + + test('retries exactly once with the supported version on a -32004 rejection', async () => { + stub = await startStubServer((recorded, index, res) => { + if (index === 1) { + respondJson(res, 400, { + jsonrpc: '2.0', + id: recorded.body.id, + error: { + code: -32004, + message: 'Unsupported protocol version', + data: { supported: [DRAFT_PROTOCOL_VERSION] } + } + }); + return; + } + respondJson(res, 200, { + jsonrpc: '2.0', + id: recorded.body.id, + result: { ok: true } + }); + }); + + const response = await sendStatelessRequest(stub.url, 'tools/list'); + + expect(stub.requests).toHaveLength(2); + const retryRequest = stub.requests[1]; + expect(retryRequest.headers['mcp-protocol-version']).toBe( + DRAFT_PROTOCOL_VERSION + ); + expect(retryRequest.body.params._meta[PROTOCOL_VERSION_META_KEY]).toBe( + DRAFT_PROTOCOL_VERSION + ); + // The retry reuses the original JSON-RPC id. + expect(retryRequest.body.id).toBe(stub.requests[0].body.id); + + expect(response.status).toBe(200); + expect(response.body?.result).toEqual({ ok: true }); + expect(response.versionRetry).toEqual({ + rejectedStatus: 400, + rejectedError: { code: -32004, message: 'Unsupported protocol version' }, + retriedWith: DRAFT_PROTOCOL_VERSION + }); + }); + + test('resolves promptly when an SSE response keeps the stream open', async () => { + stub = await startStubServer((recorded, _index, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }); + // The matching JSON-RPC response is written immediately, but the stream + // is deliberately never ended. + res.write( + `data: ${JSON.stringify({ + jsonrpc: '2.0', + id: recorded.body.id, + result: { ok: true } + })}\n\n` + ); + }); + + const started = Date.now(); + const response = await sendStatelessRequest(stub.url, 'tools/list'); + const elapsed = Date.now() - started; + + // Must resolve as soon as the matching event arrives, not on the timeout. + expect(elapsed).toBeLessThan(1000); + expect(response.status).toBe(200); + expect(response.contentType).toContain('text/event-stream'); + expect(response.body?.result).toEqual({ ok: true }); + expect(response.events?.length).toBeGreaterThanOrEqual(1); + }); + + test('keeps the MCP-Protocol-Version header in sync with a _meta version override', async () => { + stub = await startStubServer((recorded, _index, res) => { + respondJson(res, 200, { + jsonrpc: '2.0', + id: recorded.body.id, + result: {} + }); + }); + + await sendStatelessRequest(stub.url, 'tools/list', undefined, { + meta: { [PROTOCOL_VERSION_META_KEY]: '2025-11-25' }, + retryOnUnsupportedVersion: false + }); + + expect(stub.requests).toHaveLength(1); + const recorded = stub.requests[0]; + expect(recorded.body.params._meta[PROTOCOL_VERSION_META_KEY]).toBe( + '2025-11-25' + ); + expect(recorded.headers['mcp-protocol-version']).toBe('2025-11-25'); + }); + + test('sends the standard headers and _meta by default and honors omitHeaders', async () => { + stub = await startStubServer((recorded, _index, res) => { + respondJson(res, 200, { + jsonrpc: '2.0', + id: recorded.body.id, + result: { content: [] } + }); + }); + + await sendStatelessRequest(stub.url, 'tools/call', { + name: 'echo_tool', + arguments: {} + }); + + const conformant = stub.requests[0]; + expect(conformant.headers['mcp-method']).toBe('tools/call'); + expect(conformant.headers['mcp-name']).toBe('echo_tool'); + expect(conformant.headers['mcp-protocol-version']).toBe( + DRAFT_PROTOCOL_VERSION + ); + expect(conformant.headers['content-type']).toBe('application/json'); + expect(conformant.headers['accept']).toContain('application/json'); + expect(conformant.headers['accept']).toContain('text/event-stream'); + + const meta = conformant.body.params._meta; + expect(meta[PROTOCOL_VERSION_META_KEY]).toBe(DRAFT_PROTOCOL_VERSION); + expect(meta['io.modelcontextprotocol/clientInfo']).toMatchObject({ + name: expect.any(String), + version: expect.any(String) + }); + expect(meta['io.modelcontextprotocol/clientCapabilities']).toMatchObject({ + sampling: {}, + elicitation: {}, + roots: { listChanged: true } + }); + + await sendStatelessRequest( + stub.url, + 'tools/call', + { name: 'echo_tool', arguments: {} }, + { omitHeaders: ['Mcp-Method', 'Mcp-Name'] } + ); + + const stripped = stub.requests[1]; + expect(stripped.headers['mcp-method']).toBeUndefined(); + expect(stripped.headers['mcp-name']).toBeUndefined(); + // Untouched defaults are still sent. + expect(stripped.headers['mcp-protocol-version']).toBe( + DRAFT_PROTOCOL_VERSION + ); + }); +}); diff --git a/src/scenarios/server/stateless-client.ts b/src/scenarios/server/stateless-client.ts new file mode 100644 index 00000000..40fb7e2f --- /dev/null +++ b/src/scenarios/server/stateless-client.ts @@ -0,0 +1,503 @@ +/** + * Stateless request helpers for server scenarios: stateless requests per + * SEP-2575 plus the standard HTTP headers per SEP-2243. + * + * Every request the harness sends to a server under test has cross-cutting + * obligations that are independent of whatever a scenario is actually testing: + * + * - `MCP-Protocol-Version` header on every POST, matching + * `_meta["io.modelcontextprotocol/protocolVersion"]` in the body + * - `Mcp-Method` header mirroring the JSON-RPC `method` + * - `Mcp-Name` header mirroring `params.name` (tools/call, prompts/get) or + * `params.uri` (resources/read) + * - `_meta` carrying protocolVersion, clientInfo and clientCapabilities + * - an `Accept` header listing both `application/json` and `text/event-stream` + * + * Scenarios that exercise these SEPs MUST build their requests through these + * helpers so a strictly-conformant server never rejects harness traffic for + * reasons unrelated to the behaviour under test (issues #311, #312, #315). + * Negative tests can override or omit exactly the dimension they exercise via + * the options. The advertised protocol version defaults to + * `DRAFT_PROTOCOL_VERSION` and can be overridden per request. + * + * The harness's own conformance is enforced by + * `src/scenarios/harness-traffic-conformance.test.ts`. + */ + +import { + DRAFT_PROTOCOL_VERSION, + NEGOTIABLE_PROTOCOL_VERSIONS +} from '../../types'; + +// ─── JSON-RPC types ────────────────────────────────────────────────────────── + +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number | string | null; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +export const CONFORMANCE_CLIENT_INFO = { + name: 'conformance-test-client', + version: '1.0.0' +} as const; + +export const DEFAULT_CLIENT_CAPABILITIES = { + sampling: {}, + elicitation: {}, + roots: { listChanged: true } +} as const; + +// ─── Options ───────────────────────────────────────────────────────────────── + +export interface RequestHeaderOptions { + /** Wire protocol version to advertise (header + _meta). */ + protocolVersion?: string; + /** Extra or overriding headers (later wins; case preserved as given). */ + headers?: Record; + /** Default header names to drop entirely (case-insensitive). */ + omitHeaders?: string[]; +} + +export interface StatelessRequestOptions extends RequestHeaderOptions { + /** JSON-RPC id; auto-incremented when omitted. */ + id?: number | string; + /** + * Extra `_meta` keys merged over the conformant defaults, or `false` to + * omit `_meta` entirely (negative tests only). Keys already present in + * `params._meta` also override the defaults. + */ + meta?: Record | false; + /** Client capabilities advertised in `_meta`; defaults to all optional ones. */ + clientCapabilities?: Record; + /** + * Retry once with a server-supported version when the request is rejected + * as an unsupported protocol version (the spec SHOULD for clients). + * Defaults to true. + */ + retryOnUnsupportedVersion?: boolean; + /** Abort the request after this many milliseconds. Defaults to 10s. */ + timeoutMs?: number; +} + +export interface StatelessResponse { + status: number; + headers: Headers; + contentType?: string; + /** + * The parsed JSON-RPC message: the JSON body, or — for `text/event-stream` + * responses — the event matching the request id (falling back to the last + * response-shaped event). + */ + body?: JsonRpcResponse; + /** All parsed events when the response was an SSE / chunked stream. */ + events?: unknown[]; + /** Raw response text when it could not be parsed as JSON. */ + text?: string; + /** Populated when the request was retried after an unsupported-version 400. */ + versionRetry?: { + rejectedStatus: number; + rejectedError?: { code: number; message: string }; + retriedWith: string; + }; +} + +let nextRequestId = 1; + +// ─── Header construction ───────────────────────────────────────────────────── + +/** + * The `Mcp-Name` source field per SEP-2243: `params.name` for tools/call and + * prompts/get, `params.uri` for resources/read; absent otherwise. + */ +export function mcpNameForRequest( + method: string, + params?: Record +): string | undefined { + if (method === 'tools/call' || method === 'prompts/get') { + return typeof params?.name === 'string' ? params.name : undefined; + } + if (method === 'resources/read') { + return typeof params?.uri === 'string' ? params.uri : undefined; + } + return undefined; +} + +/** + * The protocol version a request's `_meta` would carry when the caller passes + * an override via `options.meta` or `params._meta` (string overrides only). + */ +function metaProtocolVersionOverride( + params?: Record, + meta?: Record | false +): string | undefined { + const fromOptions = meta + ? meta['io.modelcontextprotocol/protocolVersion'] + : undefined; + const fromParams = (params?._meta as Record | undefined)?.[ + 'io.modelcontextprotocol/protocolVersion' + ]; + const override = fromOptions ?? fromParams; + return typeof override === 'string' ? override : undefined; +} + +/** + * The single effective protocol version for a request: an explicit + * `options.protocolVersion` wins, then a `_meta` override (from `options.meta` + * or `params._meta`), then `DRAFT_PROTOCOL_VERSION`. The MCP-Protocol-Version + * header and `_meta` are both built from this value so they always agree + * unless the caller sets contradictory `headers`/`omitHeaders` overrides. + */ +function resolveProtocolVersion( + params: Record | undefined, + options: StatelessRequestOptions +): string { + return ( + options.protocolVersion ?? + metaProtocolVersionOverride(params, options.meta) ?? + DRAFT_PROTOCOL_VERSION + ); +} + +/** + * Build the conformant header set for a stateless request: Content-Type, + * Accept (both content types), MCP-Protocol-Version, Mcp-Method and (when the + * method carries one) Mcp-Name. Overrides win over defaults; omitHeaders + * removes defaults entirely. + */ +export function buildStandardHeaders( + method: string, + params?: Record, + options: RequestHeaderOptions = {} +): Record { + const protocolVersion = + options.protocolVersion ?? + metaProtocolVersionOverride(params) ?? + DRAFT_PROTOCOL_VERSION; + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': protocolVersion, + 'Mcp-Method': method + }; + const name = mcpNameForRequest(method, params); + if (name !== undefined) { + headers['Mcp-Name'] = name; + } + + if (options.omitHeaders) { + const omit = new Set(options.omitHeaders.map((h) => h.toLowerCase())); + for (const key of Object.keys(headers)) { + if (omit.has(key.toLowerCase())) delete headers[key]; + } + } + + if (options.headers) { + for (const [key, value] of Object.entries(options.headers)) { + // Replace any default that differs only by case, then set the override. + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === key.toLowerCase()) { + delete headers[existing]; + } + } + headers[key] = value; + } + } + + return headers; +} + +// ─── Body construction ─────────────────────────────────────────────────────── + +/** Build the conformant `_meta` object required on every stateless request. */ +export function buildRequestMeta( + overrides?: Record, + protocolVersion: string = DRAFT_PROTOCOL_VERSION, + clientCapabilities: Record = DEFAULT_CLIENT_CAPABILITIES +): Record { + return { + 'io.modelcontextprotocol/protocolVersion': protocolVersion, + 'io.modelcontextprotocol/clientInfo': CONFORMANCE_CLIENT_INFO, + 'io.modelcontextprotocol/clientCapabilities': clientCapabilities, + ...overrides + }; +} + +/** Merge params with the conformant `_meta` (or omit it when meta === false). */ +export function withRequestMeta( + params: Record | undefined, + options: StatelessRequestOptions = {} +): Record | undefined { + if (options.meta === false) { + return params; + } + const protocolVersion = resolveProtocolVersion(params, options); + return { + ...params, + _meta: buildRequestMeta( + { + ...(params?._meta as Record | undefined), + ...(options.meta ?? undefined), + // An explicit options.protocolVersion wins over any meta override so + // the MCP-Protocol-Version header and `_meta` always agree. + ...(options.protocolVersion !== undefined + ? { + 'io.modelcontextprotocol/protocolVersion': options.protocolVersion + } + : {}) + }, + protocolVersion, + options.clientCapabilities ?? DEFAULT_CLIENT_CAPABILITIES + ) + }; +} + +// ─── Response parsing ──────────────────────────────────────────────────────── + +function isJsonRpcResponseShaped(event: unknown): event is JsonRpcResponse { + return ( + typeof event === 'object' && + event !== null && + ('result' in event || 'error' in event) + ); +} + +function parseSseLineInto(events: unknown[], rawLine: string): void { + const line = rawLine.trim(); + if (!line) return; + const jsonText = line.startsWith('data:') + ? line.replace(/^data:\s*/, '') + : line; + try { + events.push(JSON.parse(jsonText)); + } catch { + // Non-JSON line (comments, partial frames) — ignore. + } +} + +/** + * Read an SSE / chunked-stream response incrementally and resolve as soon as + * the JSON-RPC response matching `requestId` arrives — without waiting for the + * server to close the stream. If the stream ends (or the request is aborted) + * before a matching event is seen, returns whatever events were parsed, with + * `body` set to the last response-shaped event if any. + */ +export async function readSseJsonRpcResponse( + res: Response, + requestId: number | string | null +): Promise<{ events: unknown[]; body?: JsonRpcResponse }> { + const events: unknown[] = []; + const matchesRequest = (event: unknown): event is JsonRpcResponse => + isJsonRpcResponseShaped(event) && event.id === requestId; + const finish = (): { events: unknown[]; body?: JsonRpcResponse } => { + const match = events.find(matchesRequest); + const lastResponseShaped = [...events] + .reverse() + .find(isJsonRpcResponseShaped); + return { events, body: match ?? lastResponseShaped }; + }; + + if (!res.body) return finish(); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + for (;;) { + let value: Uint8Array | undefined; + let done = false; + try { + ({ value, done } = await reader.read()); + } catch { + // The stream was aborted (timeout) or dropped — return what arrived. + break; + } + + if (value) { + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + for (const line of lines) parseSseLineInto(events, line); + + if (events.some(matchesRequest)) { + // The response we were waiting for arrived; stop reading the stream. + await reader.cancel().catch(() => {}); + break; + } + } + + if (done) { + parseSseLineInto(events, buffer); + buffer = ''; + break; + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // Lock already released (e.g. after cancel) — nothing to do. + } + } + + return finish(); +} + +// Error codes a server may use to reject an unsupported protocol version: +// -32004 is the dedicated UnsupportedProtocolVersionError code in the draft +// schema; -32001 and -32602 are tolerated for servers that predate it. +const UNSUPPORTED_VERSION_ERROR_CODES = new Set([-32004, -32001, -32602]); + +function parseUnsupportedVersionRejection( + status: number, + body: JsonRpcResponse | undefined +): { supported: string[] } | undefined { + if (status !== 400 || !body?.error) return undefined; + if (!UNSUPPORTED_VERSION_ERROR_CODES.has(body.error.code)) return undefined; + const data = body.error.data as { supported?: unknown } | undefined; + if (!Array.isArray(data?.supported) || data.supported.length === 0) { + return undefined; + } + const supported = data.supported.filter( + (v): v is string => typeof v === 'string' + ); + return supported.length > 0 ? { supported } : undefined; +} + +/** + * Pick the version to retry with after an unsupported-version rejection. Only + * versions the harness recognizes are eligible; returns undefined (no retry) + * when the server's supported list has no usable entry. + */ +function pickRetryVersion( + requested: string, + supported: string[] +): string | undefined { + if (supported.includes(requested)) return requested; + if (supported.includes(DRAFT_PROTOCOL_VERSION)) return DRAFT_PROTOCOL_VERSION; + return supported.find((v) => NEGOTIABLE_PROTOCOL_VERSIONS.includes(v)); +} + +// ─── Requests ──────────────────────────────────────────────────────────────── + +/** + * Send a single stateless JSON-RPC request with the full set of cross-cutting + * headers and `_meta`. Handles both JSON and SSE responses and (by default) + * retries once with a mutually supported version when the server rejects the + * advertised protocol version. + */ +export async function sendStatelessRequest( + serverUrl: string, + method: string, + params?: Record, + options: StatelessRequestOptions = {} +): Promise { + const id = options.id ?? nextRequestId++; + const response = await sendOnce(serverUrl, method, params, options, id); + + if (options.retryOnUnsupportedVersion === false) { + return response; + } + const rejection = parseUnsupportedVersionRejection( + response.status, + response.body + ); + if (!rejection) { + return response; + } + const requested = resolveProtocolVersion(params, options); + const retryVersion = pickRetryVersion(requested, rejection.supported); + if (!retryVersion) { + // The server offered no version the harness recognizes — surface the + // original rejection rather than guessing. + return response; + } + const retried = await sendOnce( + serverUrl, + method, + params, + { ...options, protocolVersion: retryVersion }, + id + ); + retried.versionRetry = { + rejectedStatus: response.status, + rejectedError: response.body?.error + ? { + code: response.body.error.code, + message: response.body.error.message + } + : undefined, + retriedWith: retryVersion + }; + return retried; +} + +async function sendOnce( + serverUrl: string, + method: string, + params: Record | undefined, + options: StatelessRequestOptions, + id: number | string +): Promise { + const protocolVersion = resolveProtocolVersion(params, options); + const headers = buildStandardHeaders(method, params, { + ...options, + protocolVersion + }); + const enrichedParams = withRequestMeta(params, options); + const body = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + ...(enrichedParams !== undefined ? { params: enrichedParams } : {}) + }); + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + options.timeoutMs ?? 10000 + ); + try { + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body, + signal: controller.signal + }); + + const contentType = res.headers.get('content-type') ?? undefined; + + if (contentType?.includes('text/event-stream')) { + // Read the stream incrementally and resolve on the matching response — + // a server that keeps the stream open must not stall the harness. + const { events, body: matched } = await readSseJsonRpcResponse(res, id); + return { + status: res.status, + headers: res.headers, + contentType, + events, + body: matched + }; + } + + const text = await res.text(); + try { + return { + status: res.status, + headers: res.headers, + contentType, + body: text ? (JSON.parse(text) as JsonRpcResponse) : undefined + }; + } catch { + return { status: res.status, headers: res.headers, contentType, text }; + } + } finally { + clearTimeout(timeout); + // Tear down any still-open SSE stream so sockets don't linger. + controller.abort(); + } +} diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index 486db4d6..ed97928c 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -7,7 +7,10 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { buildDraftHeaders } from './draft-client'; +import { + buildStandardHeaders, + readSseJsonRpcResponse +} from './stateless-client'; const SPEC_REF = [ { @@ -120,7 +123,7 @@ export class ServerStatelessScenario implements ClientScenario { // MCP-Protocol-Version) are not what this scenario exercises, so they // are always sent conformantly; overrides only alter the dimension a // test case is about (issue #312). - const headers = buildDraftHeaders(method, params, { + const headers = buildStandardHeaders(method, params, { headers: headersOverrides }); @@ -133,10 +136,22 @@ export class ServerStatelessScenario implements ClientScenario { const res = await fetch(serverUrl, { method: 'POST', headers, body }); let data: any = null; - try { - data = await res.json(); - } catch { - // Response might not be JSON + // Servers may answer single requests over text/event-stream; pick the + // JSON-RPC message matching this request id instead of failing to parse + // the stream as JSON. + const contentType = + typeof res.headers?.get === 'function' + ? (res.headers.get('content-type') ?? '') + : ''; + if (contentType.includes('text/event-stream')) { + const { body: matched } = await readSseJsonRpcResponse(res, id); + data = matched ?? null; + } else { + try { + data = await res.json(); + } catch { + // Response might not be JSON + } } return { res, data }; }; @@ -152,7 +167,7 @@ export class ServerStatelessScenario implements ClientScenario { timeoutMs = 1000, onFirstFrame?: () => Promise ): Promise => { - const headers = buildDraftHeaders(method, params); + const headers = buildStandardHeaders(method, params); const body = JSON.stringify({ jsonrpc: '2.0', From 41e9566f4b62500b78ca0b763862c1c6c8efc1f1 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 26 May 2026 20:50:41 +0100 Subject: [PATCH 4/4] refactor: slim the stateless client helper for the initial merge --- .../harness-traffic-conformance.test.ts | 108 ------ src/scenarios/server/http-standard-headers.ts | 24 +- src/scenarios/server/stateless-client.test.ts | 307 +++++------------- src/scenarios/server/stateless-client.ts | 299 ++--------------- 4 files changed, 125 insertions(+), 613 deletions(-) delete mode 100644 src/scenarios/harness-traffic-conformance.test.ts diff --git a/src/scenarios/harness-traffic-conformance.test.ts b/src/scenarios/harness-traffic-conformance.test.ts deleted file mode 100644 index 532c97e8..00000000 --- a/src/scenarios/harness-traffic-conformance.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Harness traffic conformance gate: "test conformance with conformance". - * - * The harness's own stateless server-scenario drivers (the requests we send - * when testing an SDK server) must satisfy the cross-cutting client - * obligations from SEP-2575 and SEP-2243 — otherwise a strictly-conformant SDK - * rejects harness traffic for reasons unrelated to the behaviour under test - * (issues #311, #312, #315). - * - * Rather than writing bespoke assertions, each pairing starts an existing - * client-testing Scenario as the judge (a mock server that inspects every - * incoming request and emits conformance checks) and points a stateless - * ClientScenario driver at it. The driver's own checks are irrelevant here — - * the judge's checks are the assertion. - */ -import { describe, it, expect, afterEach } from 'vitest'; -import { getClientScenario, listDraftClientScenarios } from './index'; -import { HttpStandardHeadersScenario } from './client/http-standard-headers'; -import { RequestMetadataScenario } from './client/request-metadata'; -import type { Scenario } from '../types'; - -/** - * judge scenario -> factory. - * - * Judges are instantiated fresh for every pairing (the registry instances are - * module-level singletons whose recorded checks would otherwise leak between - * pairings). - */ -const JUDGES: Record Scenario> = { - // SEP-2243: Mcp-Method / Mcp-Name headers on every request - 'http-standard-headers': () => new HttpStandardHeadersScenario(), - // SEP-2575: MCP-Protocol-Version header + complete _meta on every request - 'request-metadata': () => new RequestMetadataScenario() -}; - -/** - * Drivers excluded per judge: only scenarios that deliberately send traffic - * violating that judge's dimension are excluded. Everything else that targets - * the draft spec is paired automatically (derived from the scenario registry, - * not hand-listed). - */ -const EXCLUSIONS: Record> = { - 'http-standard-headers': new Set([ - // Deliberately sends mismatched/missing Mcp-Method and Mcp-Name headers. - 'http-header-validation', - // Deliberately sends mismatched/missing Mcp-Name and Mcp-Param-* headers. - 'http-custom-header-server-validation' - ]), - 'request-metadata': new Set([ - // Deliberately sends requests with missing/invalid _meta and mismatched - // protocol versions. - 'server-stateless' - // http-header-validation / http-custom-header-server-validation only - // mangle the SEP-2243 Mcp-* headers; their MCP-Protocol-Version header and - // _meta stay conformant, so they are judged here. - ]) -}; - -describe('harness traffic conformance (drivers judged by client scenarios)', () => { - let judge: Scenario | undefined; - - afterEach(async () => { - if (judge) { - await judge.stop().catch(() => {}); - judge = undefined; - } - }); - - for (const [judgeName, makeJudge] of Object.entries(JUDGES)) { - const driverNames = listDraftClientScenarios().filter( - (name) => !EXCLUSIONS[judgeName].has(name) - ); - - for (const driverName of driverNames) { - it(`${driverName} traffic passes the ${judgeName} checks`, async () => { - judge = makeJudge(); - const driver = getClientScenario(driverName); - expect(driver, `driver scenario ${driverName} not found`).toBeDefined(); - - const urls = await judge.start(); - try { - // The judge's mock is not a real MCP server, so the driver's own - // checks routinely fail against it — that is expected and ignored. - // Only the traffic the driver emitted matters here. - await driver!.run(urls.serverUrl).catch(() => {}); - } finally { - await judge.stop(); - } - - // WARNING also fails the gate on purpose: SHOULD-level obligations are - // mandatory for the harness's own traffic. - const verdicts = judge - .getChecks() - .filter((c) => c.status === 'FAILURE' || c.status === 'WARNING'); - expect( - verdicts, - `harness traffic from "${driverName}" violated client obligations:\n` + - verdicts - .map( - (c) => - ` ${c.id} [${c.status}] ${c.name}: ${c.errorMessage ?? c.description}` - ) - .join('\n') - ).toEqual([]); - }, 30000); - } - } -}); diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index bf490f0f..fd8135f0 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -494,7 +494,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { const requestBody = { ...body, id: body.id === 0 ? nextId() : body.id, - params: withRequestMeta(body.params, {}) + params: withRequestMeta(body.params) }; const response = await sendRawRequest(serverUrl, requestBody, { ...baseHeaders, @@ -885,13 +885,10 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario method: 'tools/call', // Issue #311: the body always carries the SEP-2575 _meta fields — // these cases only vary the Mcp-Param header value. - params: withRequestMeta( - { - name: toolName, - arguments: { ...defaultArgs, [paramName]: bodyValue } - }, - {} - ) + params: withRequestMeta({ + name: toolName, + arguments: { ...defaultArgs, [paramName]: bodyValue } + }) }, { ...baseHeaders, @@ -974,13 +971,10 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario method: 'tools/call', // Issue #311: the body always carries the SEP-2575 _meta fields — // this case only omits the Mcp-Param header. - params: withRequestMeta( - { - name: toolName, - arguments: { ...defaultArgs, [paramName]: 'test-value' } - }, - {} - ) + params: withRequestMeta({ + name: toolName, + arguments: { ...defaultArgs, [paramName]: 'test-value' } + }) }, { ...baseHeaders, diff --git a/src/scenarios/server/stateless-client.test.ts b/src/scenarios/server/stateless-client.test.ts index 6998e8be..76e21cf9 100644 --- a/src/scenarios/server/stateless-client.test.ts +++ b/src/scenarios/server/stateless-client.test.ts @@ -1,252 +1,107 @@ /** * Unit tests for the shared stateless request helper (SEP-2575 + SEP-2243): - * unsupported-version retry guard, SSE response handling, header/_meta - * protocol-version sync, and standard header defaults/omission. + * standard header defaults/overrides, `_meta` injection, and JSON parsing. */ import http from 'http'; -import { describe, test, expect, afterEach } from 'vitest'; -import { sendStatelessRequest } from './stateless-client'; +import { describe, test, expect } from 'vitest'; +import { + buildStandardHeaders, + withRequestMeta, + sendStatelessRequest, + CONFORMANCE_CLIENT_INFO, + DEFAULT_CLIENT_CAPABILITIES +} from './stateless-client'; import { DRAFT_PROTOCOL_VERSION } from '../../types'; -const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; - -interface RecordedRequest { - headers: http.IncomingHttpHeaders; - body: any; -} - -interface StubServer { - url: string; - requests: RecordedRequest[]; - close: () => Promise; -} - -function startStubServer( - handler: ( - recorded: RecordedRequest, - requestIndex: number, - res: http.ServerResponse - ) => void -): Promise { - const requests: RecordedRequest[] = []; - const server = http.createServer((req, res) => { - let body = ''; - req.on('data', (chunk) => { - body += chunk; - }); - req.on('end', () => { - const recorded: RecordedRequest = { - headers: req.headers, - body: JSON.parse(body) - }; - requests.push(recorded); - handler(recorded, requests.length, res); - }); - }); - return new Promise((resolve) => { - server.listen(0, () => { - const port = (server.address() as { port: number }).port; - resolve({ - url: `http://localhost:${port}/`, - requests, - close: () => - new Promise((resolveClose) => { - // Tear down any SSE connections deliberately left open by a test. - server.closeAllConnections(); - server.close(() => resolveClose()); - }) - }); - }); +describe('buildStandardHeaders', () => { + test('sets the standard headers pinned to the draft protocol version', () => { + const headers = buildStandardHeaders('tools/list'); + expect(headers['Mcp-Method']).toBe('tools/list'); + expect(headers['MCP-Protocol-Version']).toBe(DRAFT_PROTOCOL_VERSION); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers.Accept).toContain('application/json'); + expect(headers.Accept).toContain('text/event-stream'); + expect(headers['Mcp-Name']).toBeUndefined(); }); -} -function respondJson( - res: http.ServerResponse, - status: number, - body: unknown -): void { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(body)); -} - -describe('sendStatelessRequest', () => { - let stub: StubServer | undefined; - - afterEach(async () => { - if (stub) { - await stub.close(); - stub = undefined; - } + test('sets Mcp-Name from params.name (tools/call) and params.uri (resources/read)', () => { + expect( + buildStandardHeaders('tools/call', { name: 'echo' })['Mcp-Name'] + ).toBe('echo'); + expect( + buildStandardHeaders('resources/read', { uri: 'file:///a.txt' })[ + 'Mcp-Name' + ] + ).toBe('file:///a.txt'); }); - test('does not retry a 400 whose error code is not a version rejection', async () => { - stub = await startStubServer((recorded, _index, res) => { - // 400 with an unrelated error code whose data happens to carry an array - // named "supported" — must NOT be treated as a version rejection. - respondJson(res, 400, { - jsonrpc: '2.0', - id: recorded.body.id, - error: { - code: -32099, - message: 'scope rejected', - data: { supported: ['something-unrelated'] } - } - }); + test('overrides replace defaults case-insensitively', () => { + const headers = buildStandardHeaders('tools/list', undefined, { + headers: { 'mcp-protocol-version': '2025-06-18' } }); - - const response = await sendStatelessRequest(stub.url, 'tools/list'); - - expect(stub.requests).toHaveLength(1); - expect(response.status).toBe(400); - expect(response.body?.error?.code).toBe(-32099); - expect(response.body?.error?.message).toBe('scope rejected'); - expect(response.versionRetry).toBeUndefined(); + expect(headers['MCP-Protocol-Version']).toBeUndefined(); + expect(headers['mcp-protocol-version']).toBe('2025-06-18'); }); +}); - test('retries exactly once with the supported version on a -32004 rejection', async () => { - stub = await startStubServer((recorded, index, res) => { - if (index === 1) { - respondJson(res, 400, { - jsonrpc: '2.0', - id: recorded.body.id, - error: { - code: -32004, - message: 'Unsupported protocol version', - data: { supported: [DRAFT_PROTOCOL_VERSION] } - } - }); - return; - } - respondJson(res, 200, { - jsonrpc: '2.0', - id: recorded.body.id, - result: { ok: true } - }); - }); - - const response = await sendStatelessRequest(stub.url, 'tools/list'); - - expect(stub.requests).toHaveLength(2); - const retryRequest = stub.requests[1]; - expect(retryRequest.headers['mcp-protocol-version']).toBe( +describe('withRequestMeta', () => { + test('injects the required _meta fields', () => { + const params = withRequestMeta({ name: 'echo' }); + const meta = params._meta as Record; + expect(meta['io.modelcontextprotocol/protocolVersion']).toBe( DRAFT_PROTOCOL_VERSION ); - expect(retryRequest.body.params._meta[PROTOCOL_VERSION_META_KEY]).toBe( - DRAFT_PROTOCOL_VERSION + expect(meta['io.modelcontextprotocol/clientInfo']).toEqual( + CONFORMANCE_CLIENT_INFO ); - // The retry reuses the original JSON-RPC id. - expect(retryRequest.body.id).toBe(stub.requests[0].body.id); - - expect(response.status).toBe(200); - expect(response.body?.result).toEqual({ ok: true }); - expect(response.versionRetry).toEqual({ - rejectedStatus: 400, - rejectedError: { code: -32004, message: 'Unsupported protocol version' }, - retriedWith: DRAFT_PROTOCOL_VERSION - }); - }); - - test('resolves promptly when an SSE response keeps the stream open', async () => { - stub = await startStubServer((recorded, _index, res) => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive' - }); - // The matching JSON-RPC response is written immediately, but the stream - // is deliberately never ended. - res.write( - `data: ${JSON.stringify({ - jsonrpc: '2.0', - id: recorded.body.id, - result: { ok: true } - })}\n\n` - ); - }); - - const started = Date.now(); - const response = await sendStatelessRequest(stub.url, 'tools/list'); - const elapsed = Date.now() - started; - - // Must resolve as soon as the matching event arrives, not on the timeout. - expect(elapsed).toBeLessThan(1000); - expect(response.status).toBe(200); - expect(response.contentType).toContain('text/event-stream'); - expect(response.body?.result).toEqual({ ok: true }); - expect(response.events?.length).toBeGreaterThanOrEqual(1); + expect(meta['io.modelcontextprotocol/clientCapabilities']).toEqual( + DEFAULT_CLIENT_CAPABILITIES + ); + expect(params.name).toBe('echo'); }); - test('keeps the MCP-Protocol-Version header in sync with a _meta version override', async () => { - stub = await startStubServer((recorded, _index, res) => { - respondJson(res, 200, { - jsonrpc: '2.0', - id: recorded.body.id, - result: {} - }); - }); - - await sendStatelessRequest(stub.url, 'tools/list', undefined, { - meta: { [PROTOCOL_VERSION_META_KEY]: '2025-11-25' }, - retryOnUnsupportedVersion: false + test('keys already present in params._meta win over the defaults', () => { + const params = withRequestMeta({ + _meta: { 'io.modelcontextprotocol/protocolVersion': '2025-06-18' } }); - - expect(stub.requests).toHaveLength(1); - const recorded = stub.requests[0]; - expect(recorded.body.params._meta[PROTOCOL_VERSION_META_KEY]).toBe( - '2025-11-25' + const meta = params._meta as Record; + expect(meta['io.modelcontextprotocol/protocolVersion']).toBe('2025-06-18'); + expect(meta['io.modelcontextprotocol/clientInfo']).toEqual( + CONFORMANCE_CLIENT_INFO ); - expect(recorded.headers['mcp-protocol-version']).toBe('2025-11-25'); }); +}); - test('sends the standard headers and _meta by default and honors omitHeaders', async () => { - stub = await startStubServer((recorded, _index, res) => { - respondJson(res, 200, { - jsonrpc: '2.0', - id: recorded.body.id, - result: { content: [] } +describe('sendStatelessRequest', () => { + test('parses a plain JSON response', async () => { + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const request = JSON.parse(body); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { tools: [] } + }) + ); }); }); - - await sendStatelessRequest(stub.url, 'tools/call', { - name: 'echo_tool', - arguments: {} - }); - - const conformant = stub.requests[0]; - expect(conformant.headers['mcp-method']).toBe('tools/call'); - expect(conformant.headers['mcp-name']).toBe('echo_tool'); - expect(conformant.headers['mcp-protocol-version']).toBe( - DRAFT_PROTOCOL_VERSION - ); - expect(conformant.headers['content-type']).toBe('application/json'); - expect(conformant.headers['accept']).toContain('application/json'); - expect(conformant.headers['accept']).toContain('text/event-stream'); - - const meta = conformant.body.params._meta; - expect(meta[PROTOCOL_VERSION_META_KEY]).toBe(DRAFT_PROTOCOL_VERSION); - expect(meta['io.modelcontextprotocol/clientInfo']).toMatchObject({ - name: expect.any(String), - version: expect.any(String) - }); - expect(meta['io.modelcontextprotocol/clientCapabilities']).toMatchObject({ - sampling: {}, - elicitation: {}, - roots: { listChanged: true } - }); - - await sendStatelessRequest( - stub.url, - 'tools/call', - { name: 'echo_tool', arguments: {} }, - { omitHeaders: ['Mcp-Method', 'Mcp-Name'] } - ); - - const stripped = stub.requests[1]; - expect(stripped.headers['mcp-method']).toBeUndefined(); - expect(stripped.headers['mcp-name']).toBeUndefined(); - // Untouched defaults are still sent. - expect(stripped.headers['mcp-protocol-version']).toBe( - DRAFT_PROTOCOL_VERSION - ); + await new Promise((resolve) => server.listen(0, resolve)); + const port = (server.address() as { port: number }).port; + try { + const response = await sendStatelessRequest( + `http://localhost:${port}/`, + 'tools/list' + ); + expect(response.status).toBe(200); + expect(response.body?.result).toEqual({ tools: [] }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } }); }); diff --git a/src/scenarios/server/stateless-client.ts b/src/scenarios/server/stateless-client.ts index 40fb7e2f..4a66a6ba 100644 --- a/src/scenarios/server/stateless-client.ts +++ b/src/scenarios/server/stateless-client.ts @@ -1,35 +1,17 @@ /** - * Stateless request helpers for server scenarios: stateless requests per - * SEP-2575 plus the standard HTTP headers per SEP-2243. + * Stateless request helpers for server scenarios (SEP-2575 + SEP-2243). * - * Every request the harness sends to a server under test has cross-cutting - * obligations that are independent of whatever a scenario is actually testing: - * - * - `MCP-Protocol-Version` header on every POST, matching - * `_meta["io.modelcontextprotocol/protocolVersion"]` in the body - * - `Mcp-Method` header mirroring the JSON-RPC `method` - * - `Mcp-Name` header mirroring `params.name` (tools/call, prompts/get) or - * `params.uri` (resources/read) - * - `_meta` carrying protocolVersion, clientInfo and clientCapabilities - * - an `Accept` header listing both `application/json` and `text/event-stream` - * - * Scenarios that exercise these SEPs MUST build their requests through these - * helpers so a strictly-conformant server never rejects harness traffic for - * reasons unrelated to the behaviour under test (issues #311, #312, #315). - * Negative tests can override or omit exactly the dimension they exercise via - * the options. The advertised protocol version defaults to - * `DRAFT_PROTOCOL_VERSION` and can be overridden per request. - * - * The harness's own conformance is enforced by - * `src/scenarios/harness-traffic-conformance.test.ts`. + * Every request the harness sends to a server under test carries the + * cross-cutting obligations regardless of what a scenario actually tests: + * the standard headers (MCP-Protocol-Version, Mcp-Method, Mcp-Name, Accept) + * and the `_meta` fields (protocolVersion, clientInfo, clientCapabilities), + * pinned to `DRAFT_PROTOCOL_VERSION`. Scenarios exercising these SEPs MUST + * build their requests through these helpers so a strictly-conformant server + * never rejects harness traffic for reasons unrelated to the behaviour under + * test (issues #311, #312, #315). */ -import { - DRAFT_PROTOCOL_VERSION, - NEGOTIABLE_PROTOCOL_VERSIONS -} from '../../types'; - -// ─── JSON-RPC types ────────────────────────────────────────────────────────── +import { DRAFT_PROTOCOL_VERSION } from '../../types'; export interface JsonRpcResponse { jsonrpc: '2.0'; @@ -38,8 +20,6 @@ export interface JsonRpcResponse { error?: { code: number; message: string; data?: unknown }; } -// ─── Defaults ──────────────────────────────────────────────────────────────── - export const CONFORMANCE_CLIENT_INFO = { name: 'conformance-test-client', version: '1.0.0' @@ -51,64 +31,20 @@ export const DEFAULT_CLIENT_CAPABILITIES = { roots: { listChanged: true } } as const; -// ─── Options ───────────────────────────────────────────────────────────────── - -export interface RequestHeaderOptions { - /** Wire protocol version to advertise (header + _meta). */ - protocolVersion?: string; - /** Extra or overriding headers (later wins; case preserved as given). */ - headers?: Record; - /** Default header names to drop entirely (case-insensitive). */ - omitHeaders?: string[]; -} - -export interface StatelessRequestOptions extends RequestHeaderOptions { - /** JSON-RPC id; auto-incremented when omitted. */ - id?: number | string; - /** - * Extra `_meta` keys merged over the conformant defaults, or `false` to - * omit `_meta` entirely (negative tests only). Keys already present in - * `params._meta` also override the defaults. - */ - meta?: Record | false; - /** Client capabilities advertised in `_meta`; defaults to all optional ones. */ - clientCapabilities?: Record; - /** - * Retry once with a server-supported version when the request is rejected - * as an unsupported protocol version (the spec SHOULD for clients). - * Defaults to true. - */ - retryOnUnsupportedVersion?: boolean; - /** Abort the request after this many milliseconds. Defaults to 10s. */ - timeoutMs?: number; -} - export interface StatelessResponse { status: number; headers: Headers; contentType?: string; - /** - * The parsed JSON-RPC message: the JSON body, or — for `text/event-stream` - * responses — the event matching the request id (falling back to the last - * response-shaped event). - */ + /** The parsed JSON-RPC message (for SSE: the event matching the request id). */ body?: JsonRpcResponse; /** All parsed events when the response was an SSE / chunked stream. */ events?: unknown[]; /** Raw response text when it could not be parsed as JSON. */ text?: string; - /** Populated when the request was retried after an unsupported-version 400. */ - versionRetry?: { - rejectedStatus: number; - rejectedError?: { code: number; message: string }; - retriedWith: string; - }; } let nextRequestId = 1; -// ─── Header construction ───────────────────────────────────────────────────── - /** * The `Mcp-Name` source field per SEP-2243: `params.name` for tools/call and * prompts/get, `params.uri` for resources/read; absent otherwise. @@ -126,61 +62,21 @@ export function mcpNameForRequest( return undefined; } -/** - * The protocol version a request's `_meta` would carry when the caller passes - * an override via `options.meta` or `params._meta` (string overrides only). - */ -function metaProtocolVersionOverride( - params?: Record, - meta?: Record | false -): string | undefined { - const fromOptions = meta - ? meta['io.modelcontextprotocol/protocolVersion'] - : undefined; - const fromParams = (params?._meta as Record | undefined)?.[ - 'io.modelcontextprotocol/protocolVersion' - ]; - const override = fromOptions ?? fromParams; - return typeof override === 'string' ? override : undefined; -} - -/** - * The single effective protocol version for a request: an explicit - * `options.protocolVersion` wins, then a `_meta` override (from `options.meta` - * or `params._meta`), then `DRAFT_PROTOCOL_VERSION`. The MCP-Protocol-Version - * header and `_meta` are both built from this value so they always agree - * unless the caller sets contradictory `headers`/`omitHeaders` overrides. - */ -function resolveProtocolVersion( - params: Record | undefined, - options: StatelessRequestOptions -): string { - return ( - options.protocolVersion ?? - metaProtocolVersionOverride(params, options.meta) ?? - DRAFT_PROTOCOL_VERSION - ); -} - /** * Build the conformant header set for a stateless request: Content-Type, * Accept (both content types), MCP-Protocol-Version, Mcp-Method and (when the - * method carries one) Mcp-Name. Overrides win over defaults; omitHeaders - * removes defaults entirely. + * method carries one) Mcp-Name. `options.headers` overrides or extends the + * defaults, replacing any default whose name matches case-insensitively. */ export function buildStandardHeaders( method: string, params?: Record, - options: RequestHeaderOptions = {} + options: { headers?: Record } = {} ): Record { - const protocolVersion = - options.protocolVersion ?? - metaProtocolVersionOverride(params) ?? - DRAFT_PROTOCOL_VERSION; const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', - 'MCP-Protocol-Version': protocolVersion, + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION, 'Mcp-Method': method }; const name = mcpNameForRequest(method, params); @@ -188,13 +84,6 @@ export function buildStandardHeaders( headers['Mcp-Name'] = name; } - if (options.omitHeaders) { - const omit = new Set(options.omitHeaders.map((h) => h.toLowerCase())); - for (const key of Object.keys(headers)) { - if (omit.has(key.toLowerCase())) delete headers[key]; - } - } - if (options.headers) { for (const [key, value] of Object.entries(options.headers)) { // Replace any default that differs only by case, then set the override. @@ -210,53 +99,24 @@ export function buildStandardHeaders( return headers; } -// ─── Body construction ─────────────────────────────────────────────────────── - -/** Build the conformant `_meta` object required on every stateless request. */ -export function buildRequestMeta( - overrides?: Record, - protocolVersion: string = DRAFT_PROTOCOL_VERSION, - clientCapabilities: Record = DEFAULT_CLIENT_CAPABILITIES -): Record { - return { - 'io.modelcontextprotocol/protocolVersion': protocolVersion, - 'io.modelcontextprotocol/clientInfo': CONFORMANCE_CLIENT_INFO, - 'io.modelcontextprotocol/clientCapabilities': clientCapabilities, - ...overrides - }; -} - -/** Merge params with the conformant `_meta` (or omit it when meta === false). */ +/** + * Merge params with the conformant `_meta` required on every stateless + * request. Keys already present in `params._meta` win over the defaults. + */ export function withRequestMeta( - params: Record | undefined, - options: StatelessRequestOptions = {} -): Record | undefined { - if (options.meta === false) { - return params; - } - const protocolVersion = resolveProtocolVersion(params, options); + params?: Record +): Record { return { ...params, - _meta: buildRequestMeta( - { - ...(params?._meta as Record | undefined), - ...(options.meta ?? undefined), - // An explicit options.protocolVersion wins over any meta override so - // the MCP-Protocol-Version header and `_meta` always agree. - ...(options.protocolVersion !== undefined - ? { - 'io.modelcontextprotocol/protocolVersion': options.protocolVersion - } - : {}) - }, - protocolVersion, - options.clientCapabilities ?? DEFAULT_CLIENT_CAPABILITIES - ) + _meta: { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': CONFORMANCE_CLIENT_INFO, + 'io.modelcontextprotocol/clientCapabilities': DEFAULT_CLIENT_CAPABILITIES, + ...(params?._meta as Record | undefined) + } }; } -// ─── Response parsing ──────────────────────────────────────────────────────── - function isJsonRpcResponseShaped(event: unknown): event is JsonRpcResponse { return ( typeof event === 'object' && @@ -280,10 +140,9 @@ function parseSseLineInto(events: unknown[], rawLine: string): void { /** * Read an SSE / chunked-stream response incrementally and resolve as soon as - * the JSON-RPC response matching `requestId` arrives — without waiting for the - * server to close the stream. If the stream ends (or the request is aborted) - * before a matching event is seen, returns whatever events were parsed, with - * `body` set to the last response-shaped event if any. + * the JSON-RPC response matching `requestId` arrives, without waiting for the + * stream to close. If the stream ends (or is aborted) first, returns whatever + * events were parsed, with `body` set to the last response-shaped event. */ export async function readSseJsonRpcResponse( res: Response, @@ -347,113 +206,25 @@ export async function readSseJsonRpcResponse( return finish(); } -// Error codes a server may use to reject an unsupported protocol version: -// -32004 is the dedicated UnsupportedProtocolVersionError code in the draft -// schema; -32001 and -32602 are tolerated for servers that predate it. -const UNSUPPORTED_VERSION_ERROR_CODES = new Set([-32004, -32001, -32602]); - -function parseUnsupportedVersionRejection( - status: number, - body: JsonRpcResponse | undefined -): { supported: string[] } | undefined { - if (status !== 400 || !body?.error) return undefined; - if (!UNSUPPORTED_VERSION_ERROR_CODES.has(body.error.code)) return undefined; - const data = body.error.data as { supported?: unknown } | undefined; - if (!Array.isArray(data?.supported) || data.supported.length === 0) { - return undefined; - } - const supported = data.supported.filter( - (v): v is string => typeof v === 'string' - ); - return supported.length > 0 ? { supported } : undefined; -} - -/** - * Pick the version to retry with after an unsupported-version rejection. Only - * versions the harness recognizes are eligible; returns undefined (no retry) - * when the server's supported list has no usable entry. - */ -function pickRetryVersion( - requested: string, - supported: string[] -): string | undefined { - if (supported.includes(requested)) return requested; - if (supported.includes(DRAFT_PROTOCOL_VERSION)) return DRAFT_PROTOCOL_VERSION; - return supported.find((v) => NEGOTIABLE_PROTOCOL_VERSIONS.includes(v)); -} - -// ─── Requests ──────────────────────────────────────────────────────────────── - /** * Send a single stateless JSON-RPC request with the full set of cross-cutting - * headers and `_meta`. Handles both JSON and SSE responses and (by default) - * retries once with a mutually supported version when the server rejects the - * advertised protocol version. + * headers and `_meta`. Handles both JSON and SSE responses. */ export async function sendStatelessRequest( serverUrl: string, method: string, params?: Record, - options: StatelessRequestOptions = {} -): Promise { - const id = options.id ?? nextRequestId++; - const response = await sendOnce(serverUrl, method, params, options, id); - - if (options.retryOnUnsupportedVersion === false) { - return response; - } - const rejection = parseUnsupportedVersionRejection( - response.status, - response.body - ); - if (!rejection) { - return response; - } - const requested = resolveProtocolVersion(params, options); - const retryVersion = pickRetryVersion(requested, rejection.supported); - if (!retryVersion) { - // The server offered no version the harness recognizes — surface the - // original rejection rather than guessing. - return response; - } - const retried = await sendOnce( - serverUrl, - method, - params, - { ...options, protocolVersion: retryVersion }, - id - ); - retried.versionRetry = { - rejectedStatus: response.status, - rejectedError: response.body?.error - ? { - code: response.body.error.code, - message: response.body.error.message - } - : undefined, - retriedWith: retryVersion - }; - return retried; -} - -async function sendOnce( - serverUrl: string, - method: string, - params: Record | undefined, - options: StatelessRequestOptions, - id: number | string + options: { headers?: Record; timeoutMs?: number } = {} ): Promise { - const protocolVersion = resolveProtocolVersion(params, options); + const id = nextRequestId++; const headers = buildStandardHeaders(method, params, { - ...options, - protocolVersion + headers: options.headers }); - const enrichedParams = withRequestMeta(params, options); const body = JSON.stringify({ jsonrpc: '2.0', id, method, - ...(enrichedParams !== undefined ? { params: enrichedParams } : {}) + params: withRequestMeta(params) }); const controller = new AbortController();