From 6e9c80fbee9e360cb1434ccfdfa47c171c3991cf Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 16:04:11 +0000 Subject: [PATCH] fix: enforce HTTP 400 and data.requested for per-request _meta and version errors The everything-server returned its -32602 missing-_meta rejection with HTTP 200; the draft spec requires 400 Bad Request. It also applied the per-request _meta validation to sessionless requests carrying a legacy session-era version header, although per-request metadata is a 2026-07-28 requirement; such requests now fall through to the session path. The server-stateless scenario gains a companion check (sep-2575-http-server-meta-invalid-400) requiring HTTP 400 on those rejections, and sep-2575-server-unsupported-version-error now also requires error.data.requested to echo the requested version. Two new negative tests pin both. --- .../servers/typescript/everything-server.ts | 26 +++++- src/scenarios/server/stateless.test.ts | 87 +++++++++++++++++++ src/scenarios/server/stateless.ts | 70 +++++++++++---- 3 files changed, 163 insertions(+), 20 deletions(-) diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index fb0d6bb5..f8a02720 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1225,6 +1225,16 @@ app.use( }) ); +// Protocol revisions that use the initialize/session lifecycle. The +// per-request `_meta` and header/body validation requirements apply to +// 2026-07-28 and later, not to traffic from these revisions. +const LEGACY_SESSION_PROTOCOL_VERSIONS = [ + '2024-11-05', + '2025-03-26', + '2025-06-18', + '2025-11-25' +]; + // Handle POST requests - stateful mode app.post('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; @@ -1236,7 +1246,15 @@ app.post('/mcp', async (req, res) => { const meta = params._meta; const metaVersion = meta?.['io.modelcontextprotocol/protocolVersion']; - if (!sessionId && (reqVersion || meta)) { + // A request that carries no `_meta` and names a legacy session-era revision + // in the header is legacy traffic; it is served by the session path below + // instead of being rejected for missing per-request metadata. + const isLegacySessionEraRequest = + meta === undefined && + reqVersion !== undefined && + LEGACY_SESSION_PROTOCOL_VERSIONS.includes(reqVersion); + + if (!sessionId && (reqVersion || meta) && !isLegacySessionEraRequest) { // Missing Transport Header Validation Check if (!reqVersion) { return res.status(400).json({ @@ -1246,14 +1264,16 @@ app.post('/mcp', async (req, res) => { }); } - // Per-Request Metadata Integrity Checks (Fields verification) + // Per-Request Metadata Integrity Checks (Fields verification). + // A request missing any required `_meta` field is malformed: -32602 and, + // on HTTP, status 400 Bad Request. if ( !meta || !meta['io.modelcontextprotocol/protocolVersion'] || !meta['io.modelcontextprotocol/clientInfo'] || !meta['io.modelcontextprotocol/clientCapabilities'] ) { - return res.status(200).json({ + return res.status(400).json({ jsonrpc: '2.0', id, error: { diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index 133d9328..a2fb5f32 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -127,6 +127,93 @@ describe('Stateless Server Scenario Negative Tests', () => { expect(missingVersionCheck?.status).toBe('FAILURE'); }); + test('Fails validation when missing-_meta rejections are returned with HTTP 200', async () => { + // This bad server picks the right JSON-RPC error code but the wrong HTTP + // status: the spec requires 400 Bad Request for malformed requests. + const mockUrl = mockFetchTarget((reqBody) => { + const meta = reqBody.params?._meta; + const missingRequired = + !meta || + !meta['io.modelcontextprotocol/protocolVersion'] || + !meta['io.modelcontextprotocol/clientInfo'] || + !meta['io.modelcontextprotocol/clientCapabilities']; + if (missingRequired) { + return { + status: 200, // Spec Violation: must be HTTP 400 + body: { + jsonrpc: '2.0', + id: reqBody.id, + error: { + code: -32602, + message: 'Invalid params: missing _meta or required fields' + } + } + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + // The JSON-RPC code is correct, so the per-field checks pass; the wrong + // HTTP status is caught by the companion status check. + const missingMetaCheck = findCheck( + checks, + 'sep-2575-request-meta-invalid-missing-meta' + ); + const httpStatusCheck = findCheck( + checks, + 'sep-2575-http-server-meta-invalid-400' + ); + + expect(missingMetaCheck?.status).toBe('SUCCESS'); + expect(httpStatusCheck?.status).toBe('FAILURE'); + }); + + test('Fails validation when the unsupported-version error omits data.requested', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + const meta = reqBody.params?._meta; + if (meta?.['io.modelcontextprotocol/protocolVersion'] === 'v999.0.0') { + return { + status: 400, + body: { + jsonrpc: '2.0', + id: reqBody.id, + error: { + code: -32004, + message: 'Unsupported protocol version', + // Spec Violation: data.requested is a required member + data: { supported: ['2026-07-28'] } + } + } + }; + } + if (reqBody.method === 'server/discover') { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 'no-requested-server', version: '1.0.0' } + } + } + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const negotiationCheck = findCheck( + checks, + 'sep-2575-server-unsupported-version-error' + ); + expect(negotiationCheck?.status).toBe('FAILURE'); + }); + test('Fails validation if removed legacy RPCs do not return HTTP 404 Not Found', async () => { // This bad server intercepts the removed 'ping' or 'initialize' methods but incorrectly returns HTTP 200 const mockUrl = mockFetchTarget((reqBody) => { diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index f6dfc784..a995b83b 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -33,8 +33,8 @@ export class ServerStatelessScenario implements ClientScenario { **Grouped Specification Requirements**: -1. **Per-Request _meta Validation (4 Checks)** - - Rejects requests missing \`_meta\` or lacking structural required internal subfields (\`protocolVersion\`, \`clientInfo\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature. +1. **Per-Request _meta Validation (5 Checks)** + - Rejects requests missing \`_meta\` or lacking structural required internal subfields (\`protocolVersion\`, \`clientInfo\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature and an HTTP status code \`400 Bad Request\`. 2. **Discovery & Capabilities (3 Checks)** - Implements \`server/discover\` mapping exact mandatory protocol elements. - Dynamically checks prompt capability declaration constraints, validates that active RPC handlers match advertised discovery capacities. @@ -356,26 +356,50 @@ export class ServerStatelessScenario implements ClientScenario { } ]; for (const testCase of metaValidationTestCases) { + const metaProbe = await sendRpc( + 'server/discover', + testCase.params, + undefined, + testCase.rpcId + ).catch(() => null); + const metaRes: any = metaProbe?.res ?? null; + const metaData: any = metaProbe?.data ?? null; + if (metaData) checkErrorId(metaData, testCase.rpcId); + await runCheck( `sep-2575-request-meta-invalid-${testCase.slug}`, 'RequestMetaInvalid', testCase.description, - async () => { - const { data } = await sendRpc( - 'server/discover', - testCase.params, - undefined, - testCase.rpcId - ); - checkErrorId(data, testCase.rpcId); + () => { + if (!metaProbe) + return { error: '_meta validation probe failed completely' }; + if (metaData?.error?.code !== -32602) { + return { + error: `Expected error code -32602, got ${metaData?.error?.code}`, + details: { fieldIssue: testCase.slug, response: metaData } + }; + } + return { details: { fieldIssue: testCase.slug, response: metaData } }; + }, + { fieldIssue: testCase.slug } + ); - if (data?.error?.code !== -32602) { + // Companion HTTP-status check: a request missing a required _meta field + // is malformed and, on HTTP, the rejection MUST use 400 Bad Request. + await runCheck( + 'sep-2575-http-server-meta-invalid-400', + 'HttpServerMetaInvalid400', + 'Rejections of requests missing required _meta fields use HTTP 400 Bad Request.', + () => { + if (!metaRes) + return { error: '_meta validation probe failed completely' }; + if (metaRes.status !== 400) { return { - error: `Expected error code -32602, got ${data?.error?.code}`, - details: { fieldIssue: testCase.slug, response: data } + error: `Expected HTTP 400 Bad Request, got status code ${metaRes.status}`, + details: { fieldIssue: testCase.slug, response: metaData } }; } - return { details: { fieldIssue: testCase.slug, response: data } }; + return { details: { fieldIssue: testCase.slug, response: metaData } }; }, { fieldIssue: testCase.slug } ); @@ -498,14 +522,15 @@ export class ServerStatelessScenario implements ClientScenario { // ========================================== // 3. Version Negotiation & Headers (3 Checks) // ========================================== + const requestedUnsupportedVersion = 'v999.0.0'; const unsupportedMeta = { ...validMeta, - 'io.modelcontextprotocol/protocolVersion': 'v999.0.0' + 'io.modelcontextprotocol/protocolVersion': requestedUnsupportedVersion }; const response301 = await sendRpc( 'server/discover', { _meta: unsupportedMeta }, - { 'MCP-Protocol-Version': 'v999.0.0' }, + { 'MCP-Protocol-Version': requestedUnsupportedVersion }, 301 ).catch(() => null); const res301: any = response301?.res ?? null; @@ -515,7 +540,7 @@ export class ServerStatelessScenario implements ClientScenario { await runCheck( 'sep-2575-server-unsupported-version-error', 'ServerUnsupportedVersionError', - '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.', + '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; the error data carries the supported versions and echoes the requested version.', () => { if (!data301) return { error: 'Unsupported version invocation failed completely' }; @@ -533,6 +558,17 @@ export class ServerStatelessScenario implements ClientScenario { return { error: `Returned supported versions data layout does not correlate to active server metrics: ${JSON.stringify(errSupportedVersions)}` }; + + // UnsupportedProtocolVersionError data carries both required members: + // `supported` (asserted above) and `requested`, which echoes the + // version the request asked for. + const requestedEcho = data301?.error?.data?.requested; + if (requestedEcho !== requestedUnsupportedVersion) { + return { + error: `error.data.requested must echo the requested version '${requestedUnsupportedVersion}', got ${JSON.stringify(requestedEcho)}`, + details: { response: data301 } + }; + } return { details: { response: data301 } }; } );