From 0cf3fe0895dc84f9bb89c6e07295776058ab1ea9 Mon Sep 17 00:00:00 2001 From: canardleteer Date: Thu, 2 Jul 2026 11:12:20 -0700 Subject: [PATCH] fix(json-rpc-batch-rejection): reject JSON-RPC batch arrays from 2025-06-18+ Add server scenario with check json-rpc-batch-rejected for spec versions where POST bodies MUST be a single JSON-RPC message. Stateful probe initializes a session before posting a [ping, ping] batch; draft uses a stateless two-method batch. Includes negative fixture, unit tests, vitest with draft lifecycle and acceptance details, and everything-server array guard for all-scenarios.test.ts. Fixes #378 --- .../typescript/accepts-json-rpc-batch.ts | 91 ++++++ .../servers/typescript/everything-server.ts | 19 ++ src/scenarios/index.ts | 3 + .../server/json-rpc-batch-rejection.test.ts | 63 ++++ .../server/json-rpc-batch-rejection.ts | 280 ++++++++++++++++++ src/scenarios/server/negative.test.ts | 38 +++ 6 files changed, 494 insertions(+) create mode 100644 examples/servers/typescript/accepts-json-rpc-batch.ts create mode 100644 src/scenarios/server/json-rpc-batch-rejection.test.ts create mode 100644 src/scenarios/server/json-rpc-batch-rejection.ts diff --git a/examples/servers/typescript/accepts-json-rpc-batch.ts b/examples/servers/typescript/accepts-json-rpc-batch.ts new file mode 100644 index 00000000..f899b28f --- /dev/null +++ b/examples/servers/typescript/accepts-json-rpc-batch.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +/** + * Negative test server that incorrectly accepts JSON-RPC batch arrays. + * + * AGENTS.md negative-fixture pattern: deliberately broken server in + * examples/servers/typescript/, exercised from negative.test.ts (not + * everything-server). Proves json-rpc-batch-rejected emits FAILURE when a + * server returns 200 with a batch response array. + */ + +import express from 'express'; + +function handleSingle(body: { + id?: number | string | null; + method?: string; + params?: Record; +}) { + const id = body.id ?? null; + const method = body.method; + + switch (method) { + case 'initialize': + return { + jsonrpc: '2.0' as const, + id, + result: { + protocolVersion: + (body.params?.protocolVersion as string | undefined) ?? + '2025-11-25', + capabilities: {}, + serverInfo: { name: 'accepts-json-rpc-batch', version: '1.0.0' } + } + }; + case 'ping': + return { jsonrpc: '2.0' as const, id, result: {} }; + case 'server/discover': + return { + jsonrpc: '2.0' as const, + id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 'accepts-json-rpc-batch', version: '1.0.0' } + } + }; + case 'tools/list': + return { + jsonrpc: '2.0' as const, + id, + result: { tools: [] } + }; + default: + return { + jsonrpc: '2.0' as const, + id, + error: { code: -32601, message: 'Method not found' } + }; + } +} + +const app = express(); +app.use(express.json()); + +app.post('/mcp', (req, res) => { + const body = req.body; + + if (Array.isArray(body)) { + const responses = body.map((item) => + handleSingle( + typeof item === 'object' && item !== null + ? (item as { + id?: number | string | null; + method?: string; + params?: Record; + }) + : { id: null } + ) + ); + return res.status(200).json(responses); + } + + return res.json(handleSingle(body ?? {})); +}); + +const PORT = parseInt(process.env.PORT || '3008', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log( + `JSON-RPC batch acceptance negative test server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 39be2f06..dd43e028 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1237,6 +1237,25 @@ const LEGACY_SESSION_PROTOCOL_VERSIONS = [ // Handle POST requests - stateful mode app.post('/mcp', async (req, res) => { + // AGENTS.md: all-scenarios.test.ts runs every active scenario against + // everything-server as the reference "does not false-positive" fixture. + // Batch arrays must be rejected from 2025-06-18 onward, but this handler + // reads req.body.method before the SDK transport sees the POST body. Without + // an explicit array guard, batch probes either hit session routing (-32000) + // for the wrong reason or reach the transport and get processed. Failure + // proof lives in accepts-json-rpc-batch.ts + negative.test.ts; this guard + // keeps the reference server aligned with the json-rpc-batch-rejection check. + if (Array.isArray(req.body)) { + return res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32600, + message: 'Invalid Request: JSON-RPC batch requests are not supported' + }, + id: null + }); + } + const sessionId = req.headers['mcp-session-id'] as string | undefined; const reqVersion = req.headers['mcp-protocol-version'] as string | undefined; const body = req.body || {}; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index db1584ea..5c3ffd6c 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -66,6 +66,7 @@ import { } from './server/prompts'; import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { JsonRpcBatchRejectionScenario } from './server/json-rpc-batch-rejection'; import { CachingScenario } from './server/caching'; // InputRequiredResult scenarios from (SEP-2322) @@ -209,6 +210,8 @@ const allClientScenariosList: ClientScenario[] = [ // Security scenarios new DNSRebindingProtectionScenario(), + // 2025-06-18+ wire requirement; negative proof in accepts-json-rpc-batch.ts + new JsonRpcBatchRejectionScenario(), // Caching scenarios (SEP-2549) new CachingScenario(), diff --git a/src/scenarios/server/json-rpc-batch-rejection.test.ts b/src/scenarios/server/json-rpc-batch-rejection.test.ts new file mode 100644 index 00000000..cb84c20c --- /dev/null +++ b/src/scenarios/server/json-rpc-batch-rejection.test.ts @@ -0,0 +1,63 @@ +// Unit tests for batch acceptance/rejection helpers used by +// json-rpc-batch-rejection.ts (AGENTS.md: prove the check logic, not only E2E). +import { describe, it, expect } from 'vitest'; +import { + isBatchAccepted, + isBatchRejected, + jsonRpcErrorCode +} from './json-rpc-batch-rejection.js'; + +describe('json-rpc batch rejection helpers', () => { + it('detects a successful batch array response as accepted', () => { + expect( + isBatchAccepted(200, [ + { jsonrpc: '2.0', id: 1, result: {} }, + { jsonrpc: '2.0', id: 2, result: {} } + ]) + ).toBe(true); + }); + + it('detects a single-object success response as accepted', () => { + expect(isBatchAccepted(200, { jsonrpc: '2.0', id: 1, result: {} })).toBe( + true + ); + }); + + it('detects HTTP 4xx JSON-RPC errors as rejected', () => { + expect( + isBatchRejected(400, { + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Invalid Request' } + }) + ).toBe(true); + expect( + isBatchRejected(400, { + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Invalid or missing session ID' } + }) + ).toBe(true); + }); + + it('does not treat HTTP 5xx as batch rejection', () => { + expect( + isBatchRejected(500, { + jsonrpc: '2.0', + id: null, + error: { code: -32603, message: 'Internal error' } + }) + ).toBe(false); + }); + + it('extracts JSON-RPC error codes from single-object bodies', () => { + expect( + jsonRpcErrorCode({ + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Invalid Request' } + }) + ).toBe(-32600); + expect(jsonRpcErrorCode([{ error: { code: -32600 } }])).toBeUndefined(); + }); +}); diff --git a/src/scenarios/server/json-rpc-batch-rejection.ts b/src/scenarios/server/json-rpc-batch-rejection.ts new file mode 100644 index 00000000..9cf02b5a --- /dev/null +++ b/src/scenarios/server/json-rpc-batch-rejection.ts @@ -0,0 +1,280 @@ +/** + * JSON-RPC batch rejection test scenario for MCP servers. + * + * Batch arrays violate the Streamable HTTP transport MUST: "The body of the POST + * request MUST be a single JSON-RPC request, notification, or response." + * + * Probe design (AGENTS.md: distinguish rejection from unrelated errors): + * - Stateful (2025-06-18 / 2025-11-25): initialize first, then POST a two-request + * ping batch with Mcp-Session-Id so the check exercises batch handling on an + * established session rather than "missing session ID" routing. + * - Draft (stateless): POST a two-method batch with per-request _meta. + * + * Success criteria: any HTTP 4xx with a single JSON-RPC error object. We do + * not require a specific error code (-32600 vs implementation-defined -320xx) + * because SDKs and wrappers disagree today; acceptance is 2xx with batch or + * single-request results. Negative proof: accepts-json-rpc-batch.ts. + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + type SpecVersion +} from '../../types'; +import { buildStandardHeaders, type RunContext } from '../../connection'; +import { request } from 'undici'; + +const SPEC_REFERENCES = [ + { + id: 'MCP-Transports-POST-Body', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server' + }, + { + id: 'MCP-Transports-POST-Body-2025-11-25', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server' + }, + { + id: 'MCP-2025-06-18-Changelog', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/changelog#major-changes' + } +]; + +const CLIENT_INFO = { + name: 'conformance-json-rpc-batch-test', + version: '1.0.0' +}; + +function buildStatelessBatch(specVersion: string): unknown[] { + return [ + { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': specVersion, + 'io.modelcontextprotocol/clientInfo': CLIENT_INFO, + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + }, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': specVersion, + 'io.modelcontextprotocol/clientInfo': CLIENT_INFO, + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + } + ]; +} + +function buildStatefulBatch(): unknown[] { + return [ + { jsonrpc: '2.0', id: 901, method: 'ping', params: {} }, + { jsonrpc: '2.0', id: 902, method: 'ping', params: {} } + ]; +} + +export function jsonRpcErrorCode(body: unknown): number | undefined { + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return undefined; + } + const error = (body as { error?: { code?: unknown } }).error; + if (typeof error !== 'object' || error === null) return undefined; + return typeof error.code === 'number' ? error.code : undefined; +} + +/** True when the server appears to have processed the batch successfully. */ +export function isBatchAccepted(statusCode: number, body: unknown): boolean { + if (statusCode >= 200 && statusCode < 300 && Array.isArray(body)) { + return true; + } + if (statusCode >= 200 && statusCode < 300 && !Array.isArray(body)) { + const result = (body as { result?: unknown })?.result; + if (result !== undefined) { + return true; + } + } + return false; +} + +/** True when the server rejected the batch with an HTTP 4xx JSON-RPC error. */ +export function isBatchRejected(statusCode: number, body: unknown): boolean { + // Intentionally any 4xx + JSON-RPC error, not only -32600: reference servers + // and SDK wrappers may surface batch rejection through different codes. + if (isBatchAccepted(statusCode, body)) { + return false; + } + return ( + statusCode >= 400 && + statusCode < 500 && + jsonRpcErrorCode(body) !== undefined + ); +} + +async function establishStatefulSession( + serverUrl: string, + specVersion: SpecVersion +): Promise { + // Raw initialize (not ctx.connect()) so the subsequent batch POST is the + // only array on the wire under test; session id comes from response headers. + const params = { + protocolVersion: specVersion, + capabilities: {}, + clientInfo: CLIENT_INFO + }; + const response = await request(serverUrl, { + method: 'POST', + headers: buildStandardHeaders('initialize', params, { specVersion }), + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params + }) + }); + + const sessionId = response.headers['mcp-session-id'] as string | undefined; + await response.body.text(); + + if (!sessionId) { + throw new Error('initialize did not return Mcp-Session-Id header'); + } + + return sessionId; +} + +async function sendJsonRpcBatch( + serverUrl: string, + specVersion: SpecVersion, + batch: unknown[], + extraHeaders: Record = {} +): Promise<{ statusCode: number; body: unknown }> { + const first = batch[0] as { + method: string; + params?: Record; + }; + const response = await request(serverUrl, { + method: 'POST', + headers: buildStandardHeaders(first.method, first.params, { + specVersion, + headers: extraHeaders + }), + body: JSON.stringify(batch) + }); + + let body: unknown; + try { + body = await response.body.json(); + } catch { + body = null; + } + + return { + statusCode: response.statusCode, + body + }; +} + +export class JsonRpcBatchRejectionScenario implements ClientScenario { + name = 'json-rpc-batch-rejection'; + readonly source = { introducedIn: '2025-06-18' } as const; + description = `Test that the server rejects JSON-RPC batch requests. + +**Scope:** From 2025-06-18 onward, Streamable HTTP POST bodies **MUST** be a single JSON-RPC message (not a JSON array). + +**Requirements:** +- Per [transports#sending-messages-to-the-server](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server): the POST body **MUST** be a single JSON-RPC _request_, _notification_, or _response_; a JSON array **MUST** be rejected (HTTP \`4xx\`, commonly \`400\` with a JSON-RPC error)`; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const timestamp = new Date().toISOString(); + const checkBase = { + id: 'json-rpc-batch-rejected', + name: 'JsonRpcBatchRejected', + description: + 'POST body MUST be a single JSON-RPC message; batch arrays MUST be rejected', + timestamp, + specReferences: SPEC_REFERENCES + }; + + try { + const batch = + specVersion === DRAFT_PROTOCOL_VERSION + ? buildStatelessBatch(specVersion) + : buildStatefulBatch(); + + const extraHeaders: Record = {}; + if (specVersion !== DRAFT_PROTOCOL_VERSION) { + extraHeaders['Mcp-Session-Id'] = await establishStatefulSession( + serverUrl, + specVersion + ); + } + + const response = await sendJsonRpcBatch( + serverUrl, + specVersion, + batch, + extraHeaders + ); + const errorCode = jsonRpcErrorCode(response.body); + const accepted = isBatchAccepted(response.statusCode, response.body); + const rejected = isBatchRejected(response.statusCode, response.body); + const details = { + statusCode: response.statusCode, + errorCode, + body: response.body, + batchSize: batch.length, + lifecycle: + specVersion === DRAFT_PROTOCOL_VERSION ? 'stateless' : 'stateful' + }; + + if (accepted) { + return [ + { + ...checkBase, + status: 'FAILURE', + errorMessage: + 'Server accepted a JSON-RPC batch array; batch requests are not supported from 2025-06-18 onward', + details + } + ]; + } + + if (rejected) { + return [ + { + ...checkBase, + status: 'SUCCESS', + details + } + ]; + } + + return [ + { + ...checkBase, + status: 'FAILURE', + errorMessage: + 'Server did not reject the batch with an HTTP 4xx JSON-RPC error response', + details + } + ]; + } catch (error) { + return [ + { + ...checkBase, + status: 'FAILURE', + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}` + } + ]; + } + } +} diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index 583c5ebd..42973038 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -2,6 +2,7 @@ import { testContext } from '../../connection/testing'; import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import { DNSRebindingProtectionScenario } from './dns-rebinding'; +import { JsonRpcBatchRejectionScenario } from './json-rpc-batch-rejection'; import { ResourcesNotFoundErrorScenario } from './resources'; import { CachingScenario } from './caching'; import { @@ -161,6 +162,43 @@ describe('Server scenario negative tests', () => { }, 15000); }); + describe('json-rpc-batch-rejection', () => { + // AGENTS.md: negative vitest pins the check slug, not failures.length > 0. + let serverProcess: ChildProcess | null = null; + const PORT = 3008; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/accepts-json-rpc-batch.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits FAILURE against a server that accepts JSON-RPC batch arrays', async () => { + const scenario = new JsonRpcBatchRejectionScenario(); + const checks = await scenario.run( + // Stateless broken fixture — same lifecycle as draft batch probe. + testContext(`http://localhost:${PORT}/mcp`, DRAFT_PROTOCOL_VERSION) + ); + + const batchCheck = checks.find((c) => c.id === 'json-rpc-batch-rejected'); + expect(batchCheck?.status).toBe('FAILURE'); + expect(batchCheck?.errorMessage).toContain('accepted'); + expect(batchCheck?.details).toMatchObject({ + batchSize: 2, + statusCode: 200, + lifecycle: 'stateless' + }); + }, 10000); + }); + describe('json-schema-2020-12 (SEP-2106)', () => { let serverProcess: ChildProcess | null = null; const PORT = 3007;