From 7fbfb64be1598487d9e63cd760e054a861c4275b Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 16 May 2026 02:10:48 +0530 Subject: [PATCH 1/2] feat: merge latest request-metadata scenario stacked branch --- .../clients/typescript/everything-client.ts | 73 ++++++++ src/scenarios/client/request-metadata.test.ts | 87 ++++++++++ src/scenarios/client/request-metadata.ts | 159 ++++++++++++++++++ src/scenarios/index.ts | 2 + src/seps/sep-2575.yaml | 104 ++++++++++++ 5 files changed, 425 insertions(+) create mode 100644 src/scenarios/client/request-metadata.test.ts create mode 100644 src/scenarios/client/request-metadata.ts create mode 100644 src/seps/sep-2575.yaml diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 48c107e1..c17bc11a 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -87,6 +87,79 @@ async function runBasicClient(serverUrl: string): Promise { registerScenarios(['initialize', 'tools-call'], runBasicClient); +// ============================================================================ +// request-metadata scenario (SEP-2575) +// ============================================================================ + +async function runRequestMetadataClient(serverUrl: string): Promise { + logger.debug('Starting request-metadata client flow...'); + + const meta = { + 'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1', + 'io.modelcontextprotocol/clientInfo': { + name: 'conformance-test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': { roots: {} } + }; + + // Call server/discover (optional for clients, but every POST still needs + // the header + _meta). + logger.debug('Calling server/discover...'); + const discoverResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': 'DRAFT-2026-v1' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'discover-1', + method: 'server/discover', + params: { _meta: meta } + }) + }); + + if (!discoverResponse.ok) { + throw new Error(`Discovery failed: ${discoverResponse.status}`); + } + const discoverResult = await discoverResponse.json(); + logger.debug( + 'Successfully discovered server capabilities:', + JSON.stringify(discoverResult.result) + ); + + // Call tools/list with required inline _meta tags and header + logger.debug('Calling tools/list with inline _meta...'); + const toolsResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': 'DRAFT-2026-v1' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: { _meta: meta } + }) + }); + + if (!toolsResponse.ok) { + throw new Error(`Tools list failed: ${toolsResponse.status}`); + } + const toolsResult = await toolsResponse.json(); + logger.debug( + 'Successfully listed tools statelessly:', + JSON.stringify(toolsResult.result) + ); + + logger.debug('request-metadata client flow completed successfully'); +} + +// Register the scenario handler +registerScenario('request-metadata', runRequestMetadataClient); + // ============================================================================ // Auth scenarios - well-behaved client // ============================================================================ diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts new file mode 100644 index 00000000..7d2a3fd1 --- /dev/null +++ b/src/scenarios/client/request-metadata.test.ts @@ -0,0 +1,87 @@ +import { describe, test } from 'vitest'; +import { + runClientAgainstScenario, + InlineClientRunner +} from './auth/test_helpers/testClient'; + +// A bad client that does not send _meta +async function badClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} // Missing _meta + }) + }); + return response.json(); +} + +const goodMeta = { + 'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1', + 'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +// A client that misses the HTTP header +async function missingHeaderClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, // Missing MCP-Protocol-Version header + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: goodMeta } + }) + }); + return response.json(); +} + +// A client whose header disagrees with _meta.protocolVersion +async function mismatchedHeaderClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': '2025-11-25' // != _meta.protocolVersion + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: goodMeta } + }) + }); + return response.json(); +} + +describe('request-metadata client scenario — negative tests', () => { + test('client fails when omitting _meta', async () => { + const runner = new InlineClientRunner(badClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: [ + 'sep-2575-client-populates-meta', + 'sep-2575-http-client-sends-version-header' + ] + }); + }); + + test('client fails when missing version header', async () => { + const runner = new InlineClientRunner(missingHeaderClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: ['sep-2575-http-client-sends-version-header'] + }); + }); + + test('client fails when header disagrees with _meta', async () => { + const runner = new InlineClientRunner(mismatchedHeaderClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: ['sep-2575-http-version-header-matches-meta'] + }); + }); +}); diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts new file mode 100644 index 00000000..0e2b1b23 --- /dev/null +++ b/src/scenarios/client/request-metadata.ts @@ -0,0 +1,159 @@ +import http from 'http'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; + +export class RequestMetadataScenario implements Scenario { + name = 'request-metadata'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Per-request _meta and MCP-Protocol-Version header obligations (SEP-2575)'; + + private server: http.Server | null = null; + private checks: ConformanceCheck[] = []; + + async start(): Promise { + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + this.server.on('error', reject); + this.server.listen(0, () => { + const address = this.server!.address(); + if (address && typeof address === 'object') { + resolve({ serverUrl: `http://localhost:${address.port}` }); + } + }); + }); + } + + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => { + resolve(); + }); + } else { + resolve(); + } + }); + } + + getChecks(): ConformanceCheck[] { + return this.checks; + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + const request = JSON.parse(body); + + // Extract version and headers + const meta = request.params?._meta; + const metaVersion = meta?.['io.modelcontextprotocol/protocolVersion']; + const headerVersion = req.headers['mcp-protocol-version']; + + // "Every POST request to the MCP endpoint MUST include an + // MCP-Protocol-Version header." — unconditional, so this fires for + // server/discover too. + this.checks.push({ + id: 'sep-2575-http-client-sends-version-header', + name: 'ClientSendsVersionHeader', + description: + 'Client sends MCP-Protocol-Version header on every POST', + status: headerVersion !== undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header' + } + ], + details: { method: request.method, headerVersion } + }); + + // "Every client request MUST include the following + // io.modelcontextprotocol/* fields in _meta: protocolVersion, + // clientInfo, clientCapabilities." + const hasClientInfo = meta?.['io.modelcontextprotocol/clientInfo']; + const hasCapabilities = + meta?.['io.modelcontextprotocol/clientCapabilities']; + const metaIsValid = metaVersion && hasClientInfo && hasCapabilities; + + this.checks.push({ + id: 'sep-2575-client-populates-meta', + name: 'ClientPopulatesMeta', + description: + 'Client populates _meta on every request with all three required fields', + status: metaIsValid ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/index#meta' + } + ], + details: { method: request.method, meta } + }); + + // "The header value MUST match the io.modelcontextprotocol/protocolVersion + // field carried in the request body's _meta." Only meaningful when both + // are present; absence is already covered by the two checks above. + if (headerVersion !== undefined && metaVersion !== undefined) { + this.checks.push({ + id: 'sep-2575-http-version-header-matches-meta', + name: 'ClientVersionHeaderMatchesMeta', + description: + 'MCP-Protocol-Version header matches _meta.protocolVersion', + status: headerVersion === metaVersion ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header' + } + ], + details: { headerVersion, metaVersion } + }); + } + + // server/discover is optional for clients (spec: "Clients MAY call it"), + // so no check is emitted; we still respond so a client that does call it + // proceeds normally and exercises the per-request _meta/header checks above. + if (request.method === 'server/discover') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: {}, + serverInfo: { name: 'test', version: '1.0' } + } + }) + ); + return; + } + + // Return generic response to unblock client + let result: object = {}; + if (request.method === 'tools/list') { + result = { tools: [] }; + } else if (request.method === 'tools/call') { + result = { content: [] }; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); + }); + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index a46604fc..4b812d94 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -13,6 +13,7 @@ import { InitializeScenario } from './client/initialize'; import { ToolsCallScenario } from './client/tools_call'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults'; import { SSERetryScenario } from './client/sse-retry'; +import { RequestMetadataScenario } from './client/request-metadata'; // Import all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle'; @@ -201,6 +202,7 @@ const scenariosList: Scenario[] = [ new ToolsCallScenario(), new ElicitationClientDefaultsScenario(), new SSERetryScenario(), + new RequestMetadataScenario(), ...authScenariosList, ...backcompatScenariosList, ...draftScenariosList, diff --git a/src/seps/sep-2575.yaml b/src/seps/sep-2575.yaml new file mode 100644 index 00000000..e48b1e26 --- /dev/null +++ b/src/seps/sep-2575.yaml @@ -0,0 +1,104 @@ +sep: 2575 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/lifecycle +requirements: + - check: sep-2575-client-populates-meta + text: 'Every client request MUST include the following io.modelcontextprotocol/* fields in _meta: protocolVersion, clientInfo, clientCapabilities.' + url: https://modelcontextprotocol.io/specification/draft/basic/index#meta + - check: sep-2575-server-rejects-undeclared-capability + text: 'A server MUST NOT rely on capabilities the client has not declared. If processing a request requires a capability the client did not include in io.modelcontextprotocol/clientCapabilities, the server MUST return a MissingRequiredClientCapabilityError (-32003).' + url: https://modelcontextprotocol.io/specification/draft/basic/index#meta + - check: sep-2575-missing-capability-http-400 + text: 'On HTTP, the response status MUST be 400 Bad Request [for MissingRequiredClientCapabilityError].' + url: https://modelcontextprotocol.io/specification/draft/basic/index#meta + - check: sep-2575-server-tags-subscription-id + text: 'On notifications delivered via a subscriptions/listen stream, the server MUST include io.modelcontextprotocol/subscriptionId in _meta so the client can correlate the notification with the originating subscription request.' + url: https://modelcontextprotocol.io/specification/draft/basic/index#meta + - check: sep-2575-server-stateless-no-prior-context + text: 'A server MUST NOT treat connection or process identity as a proxy for conversation or session continuity. / Servers MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity).' + - check: sep-2575-server-stateless-no-connection-reuse-required + text: 'Servers MUST NOT require that a client reuse the same connection to perform related operations.' + - check: sep-2575-server-unsupported-version-error + text: 'If the server does not implement the requested version (whether the version is unknown to the server, or is a known version the server has chosen not to support), it MUST respond with an UnsupportedProtocolVersionError listing the versions it does support.' + url: https://modelcontextprotocol.io/specification/draft/basic/lifecycle#protocol-version-negotiation + - check: sep-2575-client-retry-supported-version + text: 'The client SHOULD select a mutually supported version from the supported list and retry the request, or surface an error to the user if no compatible version exists.' + url: https://modelcontextprotocol.io/specification/draft/basic/lifecycle#protocol-version-negotiation + - check: sep-2575-server-implements-discover + text: 'Servers MUST implement server/discover.' + url: https://modelcontextprotocol.io/specification/draft/server/discover + - check: sep-2575-http-server-no-independent-requests-on-stream + text: 'The server MUST NOT send independent JSON-RPC requests on this stream. Server-to-client interactions are embedded as input requests inside an IncompleteResult.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#receiving-messages-1 + - check: sep-2575-http-server-disconnect-is-cancel + text: 'Closing the SSE response stream MUST be treated by the server as cancellation of that request.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1 + - check: sep-2575-http-server-stops-on-cancel + text: 'The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it [HTTP].' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1 + - check: sep-2575-http-client-sends-version-header + text: 'Every POST request to the MCP endpoint MUST include an MCP-Protocol-Version header.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-http-version-header-matches-meta + text: 'The header value MUST match the io.modelcontextprotocol/protocolVersion field carried in the request body _meta.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-http-server-header-mismatch-400 + text: 'If the values do not match, the server MUST reject the request with 400 Bad Request and a HeaderMismatch JSON-RPC error.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-http-server-unsupported-version-400 + text: 'If the server does not implement the requested protocol version, it MUST respond with 400 Bad Request and an UnsupportedProtocolVersionError listing its supported versions.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-http-server-method-not-found-404 + text: 'If the server does not implement the requested RPC method, it MUST respond with 404 Not Found and a JSON-RPC error with code -32601 (Method not found).' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-server-honors-notification-filter + text: 'The server MUST NOT send notification types the client has not explicitly requested.' + url: https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions#opening-a-stream + - check: sep-2575-server-sends-subscription-ack + text: 'The server MUST send notifications/subscriptions/acknowledged as the first message on the stream.' + url: https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions#acknowledgment + - check: sep-2575-client-declares-elicitation-capability + text: 'Clients that support elicitation MUST declare the elicitation capability in _meta.io.modelcontextprotocol/clientCapabilities on each request.' + url: https://modelcontextprotocol.io/specification/draft/client/elicitation#capabilities + - check: sep-2575-client-declares-roots-capability + text: 'Clients that support roots MUST declare the roots capability in _meta.io.modelcontextprotocol/clientCapabilities on each request.' + url: https://modelcontextprotocol.io/specification/draft/client/roots#capabilities + - check: sep-2575-client-declares-sampling-capability + text: 'Clients that support sampling MUST declare the sampling capability in _meta.io.modelcontextprotocol/clientCapabilities on each request.' + url: https://modelcontextprotocol.io/specification/draft/client/sampling#capabilities + - check: sep-2575-server-declares-prompts-in-discover + text: 'Servers that support prompts MUST declare the prompts capability in their DiscoverResult.' + url: https://modelcontextprotocol.io/specification/draft/server/prompts#capabilities + - check: sep-2575-server-sends-prompts-list-changed-on-subscription + text: '[A server with the listChanged] capability SHOULD send a notification to clients that have opened a subscriptions/listen stream with promptsListChanged: true.' + url: https://modelcontextprotocol.io/specification/draft/server/prompts#list-changed-notification + - check: sep-2575-server-sends-tools-list-changed-on-subscription + text: '[A server with the listChanged] capability SHOULD send a notification to clients that have opened a subscriptions/listen stream with toolsListChanged: true.' + url: https://modelcontextprotocol.io/specification/draft/server/tools#list-changed-notification + - check: sep-2575-server-no-log-without-loglevel + text: 'The server MUST NOT emit notifications/message for a request that does not include [io.modelcontextprotocol/logLevel in _meta].' + url: https://modelcontextprotocol.io/specification/draft/server/utilities/logging#per-request-log-level + + - text: 'State that needs to span multiple requests (e.g., long-running tasks, application-level handles) MUST be referenced by an explicit identifier the client passes on each request.' + excluded: 'architectural guidance, observable only via subscriptionId/task-id rows already listed' + - text: 'To distinguish notifications belonging to different concurrent subscriptions, clients MUST correlate notifications using the io.modelcontextprotocol/subscriptionId field carried in _meta.' + excluded: 'client-internal demux; not observable on the wire from the harness' + - text: 'The client SHOULD check the acknowledged filter against what it requested and handle any unsupported types gracefully.' + excluded: 'internal comparison; "gracefully" has no wire-observable definition' + - text: 'Because there is no per-request status code to drive fallback, a client that supports both eras SHOULD probe with server/discover first [stdio backward compatibility].' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/lifecycle#backward-compatibility-with-initialization-based-versions + - text: 'To cancel an in-flight request [on stdio], the client MUST send a notifications/cancelled notification referencing the request ID.' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation + - text: 'Servers SHOULD stop work on a cancelled request as soon as practical and MUST NOT send any further messages for it [stdio].' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation + - text: 'If the server process exits unexpectedly, the client SHOULD restart it.' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#unexpected-termination + - text: 'If the server returns UnsupportedProtocolVersionError, [the stdio client] SHOULD retry using one of the advertised supportedVersions rather than falling back to initialize.' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#backward-compatibility + - text: 'On stdio, if the connection is terminated and then re-established, the client MUST re-send subscriptions/listen to re-establish its subscriptions.' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions#cancellation From f560b10269b42fa2868ce0be7e4ed5f8a7677ac5 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 16 May 2026 02:40:26 +0530 Subject: [PATCH 2/2] test: implement optional capability conditional checks and simulated negotiation retry --- .../clients/typescript/everything-client.ts | 117 +++++++++----- src/scenarios/client/request-metadata.test.ts | 151 +++++++++++++++++- src/scenarios/client/request-metadata.ts | 129 ++++++++++++++- 3 files changed, 351 insertions(+), 46 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index c17bc11a..650cbf60 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -95,35 +95,93 @@ async function runRequestMetadataClient(serverUrl: string): Promise { logger.debug('Starting request-metadata client flow...'); const meta = { - 'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1', 'io.modelcontextprotocol/clientInfo': { name: 'conformance-test-client', version: '1.0.0' }, - 'io.modelcontextprotocol/clientCapabilities': { roots: {} } + 'io.modelcontextprotocol/clientCapabilities': { + tools: {}, + roots: {}, + sampling: {}, + elicitation: {} + } + }; + + let activeVersion = 'DRAFT-2026-v1'; + + const sendRequestWithNegotiation = async ( + method: string, + requestId: string | number, + params: any + ): Promise => { + const getPayload = (version: string) => ({ + jsonrpc: '2.0', + id: requestId, + method, + params: { + ...params, + _meta: { + ...params?._meta, + 'io.modelcontextprotocol/protocolVersion': version + } + } + }); + + const send = async (version: string) => { + return fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': version + }, + body: JSON.stringify(getPayload(version)) + }); + }; + + let response = await send(activeVersion); + if (response.status === 400) { + const clone = response.clone(); + try { + const errorResult = await clone.json(); + if (errorResult.error?.code === -32001) { + logger.debug( + 'Received UnsupportedProtocolVersionError, starting negotiation...' + ); + const serverSupported: string[] = + errorResult.error.data?.supported || []; + const clientSupported = ['DRAFT-2026-v1']; + const mutuallySupported = clientSupported.filter((v) => + serverSupported.includes(v) + ); + if (mutuallySupported.length > 0) { + activeVersion = mutuallySupported[0]; + logger.debug( + `Mutually supported version found: ${activeVersion}. Retrying...` + ); + response = await send(activeVersion); + } else { + logger.debug('No mutually supported version found. Aborting.'); + } + } + } catch (err) { + logger.debug('Failed to parse error response as JSON:', err); + } + } + + if (!response.ok) { + throw new Error(`${method} failed: ${response.status}`); + } + return response.json(); }; // Call server/discover (optional for clients, but every POST still needs // the header + _meta). logger.debug('Calling server/discover...'); - const discoverResponse = await fetch(serverUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'MCP-Protocol-Version': 'DRAFT-2026-v1' - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 'discover-1', - method: 'server/discover', - params: { _meta: meta } - }) - }); - - if (!discoverResponse.ok) { - throw new Error(`Discovery failed: ${discoverResponse.status}`); - } - const discoverResult = await discoverResponse.json(); + const discoverResult = await sendRequestWithNegotiation( + 'server/discover', + 'discover-1', + { _meta: meta } + ); logger.debug( 'Successfully discovered server capabilities:', JSON.stringify(discoverResult.result) @@ -131,24 +189,9 @@ async function runRequestMetadataClient(serverUrl: string): Promise { // Call tools/list with required inline _meta tags and header logger.debug('Calling tools/list with inline _meta...'); - const toolsResponse = await fetch(serverUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'MCP-Protocol-Version': 'DRAFT-2026-v1' - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - params: { _meta: meta } - }) + const toolsResult = await sendRequestWithNegotiation('tools/list', 2, { + _meta: meta }); - - if (!toolsResponse.ok) { - throw new Error(`Tools list failed: ${toolsResponse.status}`); - } - const toolsResult = await toolsResponse.json(); logger.debug( 'Successfully listed tools statelessly:', JSON.stringify(toolsResult.result) diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts index 7d2a3fd1..41ba4c42 100644 --- a/src/scenarios/client/request-metadata.test.ts +++ b/src/scenarios/client/request-metadata.test.ts @@ -1,8 +1,10 @@ -import { describe, test } from 'vitest'; +import { describe, test, expect } from 'vitest'; import { runClientAgainstScenario, InlineClientRunner } from './auth/test_helpers/testClient'; +import { getHandler } from '../../../examples/clients/typescript/everything-client'; +import { getScenario } from '../index'; // A bad client that does not send _meta async function badClient(serverUrl: string) { @@ -60,6 +62,129 @@ async function mismatchedHeaderClient(serverUrl: string) { return response.json(); } +// A client that fails to negotiate/retry on a 400 response +async function nonRetryingClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': 'DRAFT-2026-v1' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: goodMeta } + }) + }); + return response.json(); +} + +// A client that has empty version intersection and terminates +async function incompatibleVersionClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': 'UNSUPPORTED-VERSION' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { + _meta: { + ...goodMeta, + 'io.modelcontextprotocol/protocolVersion': 'UNSUPPORTED-VERSION' + } + } + }) + }); + + if (response.status === 400) { + const body = await response.json(); + if (body.error?.code === -32001) { + return body; // Abort cleanly + } + } + return response.json(); +} + +// A client that sends invalid (non-object) capabilities +async function malformedCapabilitiesClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': 'DRAFT-2026-v1' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { + _meta: { + ...goodMeta, + 'io.modelcontextprotocol/clientCapabilities': { + roots: 'malformed-string', + sampling: {}, + elicitation: true + } + } + } + }) + }); + return response.json(); +} + +describe('request-metadata client scenario — positive test', () => { + test('everything-client passes request-metadata scenario with success status for optional capabilities', async () => { + const clientFn = getHandler('request-metadata'); + if (!clientFn) { + throw new Error('No handler registered for scenario: request-metadata'); + } + + const scenario = getScenario('request-metadata'); + if (!scenario) { + throw new Error('Scenario not found'); + } + + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, 'request-metadata'); + + // Extract checks directly from the scenario instance + const checks = scenario.getChecks(); + + // 4-Line Bulk Assertion Loop + for (const check of checks) { + expect(check.status).not.toBe('FAILURE'); + expect(check.status).not.toBe('WARNING'); + } + + // Strategic Targeted Optional Assertions + expect( + checks.find((c) => c.id === 'sep-2575-client-declares-roots-capability') + ?.status + ).toBe('SUCCESS'); + expect( + checks.find( + (c) => c.id === 'sep-2575-client-declares-sampling-capability' + )?.status + ).toBe('SUCCESS'); + expect( + checks.find( + (c) => c.id === 'sep-2575-client-declares-elicitation-capability' + )?.status + ).toBe('SUCCESS'); + + // Assert version negotiation retry succeeded + expect( + checks.find((c) => c.id === 'sep-2575-client-retry-supported-version') + ?.status + ).toBe('SUCCESS'); + }); +}); + describe('request-metadata client scenario — negative tests', () => { test('client fails when omitting _meta', async () => { const runner = new InlineClientRunner(badClient); @@ -84,4 +209,28 @@ describe('request-metadata client scenario — negative tests', () => { expectedFailureSlugs: ['sep-2575-http-version-header-matches-meta'] }); }); + + test('client fails retry check when it does not handle 400 rejection', async () => { + const runner = new InlineClientRunner(nonRetryingClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: ['sep-2575-client-retry-supported-version'] + }); + }); + + test('client aborts cleanly without hanging when negotiation has empty version intersection', async () => { + const runner = new InlineClientRunner(incompatibleVersionClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: ['sep-2575-client-retry-supported-version'] + }); + }); + + test('client triggers failures for malformed capabilities', async () => { + const runner = new InlineClientRunner(malformedCapabilitiesClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: [ + 'sep-2575-client-declares-roots-capability', + 'sep-2575-client-declares-elicitation-capability' + ] + }); + }); }); diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 0e2b1b23..af1fde0d 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -14,8 +14,11 @@ export class RequestMetadataScenario implements Scenario { private server: http.Server | null = null; private checks: ConformanceCheck[] = []; + private hasSimulatedRejection = false; async start(): Promise { + this.hasSimulatedRejection = false; + this.checks = []; return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); @@ -46,6 +49,15 @@ export class RequestMetadataScenario implements Scenario { return this.checks; } + private addOrUpdateCheck(check: ConformanceCheck): void { + const index = this.checks.findIndex((c) => c.id === check.id); + if (index !== -1) { + this.checks[index] = check; + } else { + this.checks.push(check); + } + } + private handleRequest( req: http.IncomingMessage, res: http.ServerResponse @@ -62,14 +74,13 @@ export class RequestMetadataScenario implements Scenario { const metaVersion = meta?.['io.modelcontextprotocol/protocolVersion']; const headerVersion = req.headers['mcp-protocol-version']; - // "Every POST request to the MCP endpoint MUST include an + // 1. "Every POST request to the MCP endpoint MUST include an // MCP-Protocol-Version header." — unconditional, so this fires for // server/discover too. - this.checks.push({ + this.addOrUpdateCheck({ id: 'sep-2575-http-client-sends-version-header', name: 'ClientSendsVersionHeader', - description: - 'Client sends MCP-Protocol-Version header on every POST', + description: 'Client sends MCP-Protocol-Version header on every POST', status: headerVersion !== undefined ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), specReferences: [ @@ -81,7 +92,7 @@ export class RequestMetadataScenario implements Scenario { details: { method: request.method, headerVersion } }); - // "Every client request MUST include the following + // 2. "Every client request MUST include the following // io.modelcontextprotocol/* fields in _meta: protocolVersion, // clientInfo, clientCapabilities." const hasClientInfo = meta?.['io.modelcontextprotocol/clientInfo']; @@ -89,7 +100,7 @@ export class RequestMetadataScenario implements Scenario { meta?.['io.modelcontextprotocol/clientCapabilities']; const metaIsValid = metaVersion && hasClientInfo && hasCapabilities; - this.checks.push({ + this.addOrUpdateCheck({ id: 'sep-2575-client-populates-meta', name: 'ClientPopulatesMeta', description: @@ -105,11 +116,11 @@ export class RequestMetadataScenario implements Scenario { details: { method: request.method, meta } }); - // "The header value MUST match the io.modelcontextprotocol/protocolVersion + // 3. "The header value MUST match the io.modelcontextprotocol/protocolVersion // field carried in the request body's _meta." Only meaningful when both // are present; absence is already covered by the two checks above. if (headerVersion !== undefined && metaVersion !== undefined) { - this.checks.push({ + this.addOrUpdateCheck({ id: 'sep-2575-http-version-header-matches-meta', name: 'ClientVersionHeaderMatchesMeta', description: @@ -126,6 +137,108 @@ export class RequestMetadataScenario implements Scenario { }); } + // 4. Optional client capabilities conditional verification + const capabilities = meta?.['io.modelcontextprotocol/clientCapabilities']; + const checkOptionalCapability = ( + capabilityName: string, + checkId: string, + checkName: string + ) => { + let status: 'SUCCESS' | 'FAILURE' | 'SKIPPED' = 'SKIPPED'; + if (capabilities && capabilityName in capabilities) { + const val = capabilities[capabilityName]; + const isValidObject = + typeof val === 'object' && val !== null && !Array.isArray(val); + status = isValidObject ? 'SUCCESS' : 'FAILURE'; + } + this.addOrUpdateCheck({ + id: checkId, + name: checkName, + description: `Client declares valid ${capabilityName} capability if present`, + status, + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/index#capabilities' + } + ], + details: { capabilityValue: capabilities?.[capabilityName] } + }); + }; + + checkOptionalCapability( + 'roots', + 'sep-2575-client-declares-roots-capability', + 'ClientDeclaresRootsCapability' + ); + checkOptionalCapability( + 'sampling', + 'sep-2575-client-declares-sampling-capability', + 'ClientDeclaresSamplingCapability' + ); + checkOptionalCapability( + 'elicitation', + 'sep-2575-client-declares-elicitation-capability', + 'ClientDeclaresElicitationCapability' + ); + + // 5. Simulated Version Negotiation Retry Check + if (!this.hasSimulatedRejection) { + this.hasSimulatedRejection = true; + + this.addOrUpdateCheck({ + id: 'sep-2575-client-retry-supported-version', + name: 'ClientRetrySupportedVersion', + description: + 'Client retries with a supported version when first choice is rejected', + status: 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header' + } + ], + details: { headerVersion } + }); + + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id ?? null, + error: { + code: -32001, + message: 'Unsupported protocol version', + data: { + supported: [DRAFT_PROTOCOL_VERSION] + } + } + }) + ); + return; + } + + const retryCheck = this.checks.find( + (c) => c.id === 'sep-2575-client-retry-supported-version' + ); + if (retryCheck) { + if ( + headerVersion === DRAFT_PROTOCOL_VERSION && + metaVersion === DRAFT_PROTOCOL_VERSION + ) { + retryCheck.status = 'SUCCESS'; + } else { + retryCheck.status = 'WARNING'; + } + retryCheck.details = { + ...retryCheck.details, + retryHeaderVersion: headerVersion, + retryMetaVersion: metaVersion + }; + } + // server/discover is optional for clients (spec: "Clients MAY call it"), // so no check is emitted; we still respond so a client that does call it // proceeds normally and exercises the per-request _meta/header checks above.