Skip to content

Commit 0cf3fe0

Browse files
committed
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
1 parent 1ca3bc3 commit 0cf3fe0

6 files changed

Lines changed: 494 additions & 0 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Negative test server that incorrectly accepts JSON-RPC batch arrays.
5+
*
6+
* AGENTS.md negative-fixture pattern: deliberately broken server in
7+
* examples/servers/typescript/, exercised from negative.test.ts (not
8+
* everything-server). Proves json-rpc-batch-rejected emits FAILURE when a
9+
* server returns 200 with a batch response array.
10+
*/
11+
12+
import express from 'express';
13+
14+
function handleSingle(body: {
15+
id?: number | string | null;
16+
method?: string;
17+
params?: Record<string, unknown>;
18+
}) {
19+
const id = body.id ?? null;
20+
const method = body.method;
21+
22+
switch (method) {
23+
case 'initialize':
24+
return {
25+
jsonrpc: '2.0' as const,
26+
id,
27+
result: {
28+
protocolVersion:
29+
(body.params?.protocolVersion as string | undefined) ??
30+
'2025-11-25',
31+
capabilities: {},
32+
serverInfo: { name: 'accepts-json-rpc-batch', version: '1.0.0' }
33+
}
34+
};
35+
case 'ping':
36+
return { jsonrpc: '2.0' as const, id, result: {} };
37+
case 'server/discover':
38+
return {
39+
jsonrpc: '2.0' as const,
40+
id,
41+
result: {
42+
supportedVersions: ['2026-07-28'],
43+
capabilities: {},
44+
serverInfo: { name: 'accepts-json-rpc-batch', version: '1.0.0' }
45+
}
46+
};
47+
case 'tools/list':
48+
return {
49+
jsonrpc: '2.0' as const,
50+
id,
51+
result: { tools: [] }
52+
};
53+
default:
54+
return {
55+
jsonrpc: '2.0' as const,
56+
id,
57+
error: { code: -32601, message: 'Method not found' }
58+
};
59+
}
60+
}
61+
62+
const app = express();
63+
app.use(express.json());
64+
65+
app.post('/mcp', (req, res) => {
66+
const body = req.body;
67+
68+
if (Array.isArray(body)) {
69+
const responses = body.map((item) =>
70+
handleSingle(
71+
typeof item === 'object' && item !== null
72+
? (item as {
73+
id?: number | string | null;
74+
method?: string;
75+
params?: Record<string, unknown>;
76+
})
77+
: { id: null }
78+
)
79+
);
80+
return res.status(200).json(responses);
81+
}
82+
83+
return res.json(handleSingle(body ?? {}));
84+
});
85+
86+
const PORT = parseInt(process.env.PORT || '3008', 10);
87+
app.listen(PORT, '127.0.0.1', () => {
88+
console.log(
89+
`JSON-RPC batch acceptance negative test server running on http://localhost:${PORT}/mcp`
90+
);
91+
});

examples/servers/typescript/everything-server.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,6 +1237,25 @@ const LEGACY_SESSION_PROTOCOL_VERSIONS = [
12371237

12381238
// Handle POST requests - stateful mode
12391239
app.post('/mcp', async (req, res) => {
1240+
// AGENTS.md: all-scenarios.test.ts runs every active scenario against
1241+
// everything-server as the reference "does not false-positive" fixture.
1242+
// Batch arrays must be rejected from 2025-06-18 onward, but this handler
1243+
// reads req.body.method before the SDK transport sees the POST body. Without
1244+
// an explicit array guard, batch probes either hit session routing (-32000)
1245+
// for the wrong reason or reach the transport and get processed. Failure
1246+
// proof lives in accepts-json-rpc-batch.ts + negative.test.ts; this guard
1247+
// keeps the reference server aligned with the json-rpc-batch-rejection check.
1248+
if (Array.isArray(req.body)) {
1249+
return res.status(400).json({
1250+
jsonrpc: '2.0',
1251+
error: {
1252+
code: -32600,
1253+
message: 'Invalid Request: JSON-RPC batch requests are not supported'
1254+
},
1255+
id: null
1256+
});
1257+
}
1258+
12401259
const sessionId = req.headers['mcp-session-id'] as string | undefined;
12411260
const reqVersion = req.headers['mcp-protocol-version'] as string | undefined;
12421261
const body = req.body || {};

src/scenarios/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
} from './server/prompts';
6767

6868
import { DNSRebindingProtectionScenario } from './server/dns-rebinding';
69+
import { JsonRpcBatchRejectionScenario } from './server/json-rpc-batch-rejection';
6970
import { CachingScenario } from './server/caching';
7071

7172
// InputRequiredResult scenarios from (SEP-2322)
@@ -209,6 +210,8 @@ const allClientScenariosList: ClientScenario[] = [
209210

210211
// Security scenarios
211212
new DNSRebindingProtectionScenario(),
213+
// 2025-06-18+ wire requirement; negative proof in accepts-json-rpc-batch.ts
214+
new JsonRpcBatchRejectionScenario(),
212215

213216
// Caching scenarios (SEP-2549)
214217
new CachingScenario(),
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Unit tests for batch acceptance/rejection helpers used by
2+
// json-rpc-batch-rejection.ts (AGENTS.md: prove the check logic, not only E2E).
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
isBatchAccepted,
6+
isBatchRejected,
7+
jsonRpcErrorCode
8+
} from './json-rpc-batch-rejection.js';
9+
10+
describe('json-rpc batch rejection helpers', () => {
11+
it('detects a successful batch array response as accepted', () => {
12+
expect(
13+
isBatchAccepted(200, [
14+
{ jsonrpc: '2.0', id: 1, result: {} },
15+
{ jsonrpc: '2.0', id: 2, result: {} }
16+
])
17+
).toBe(true);
18+
});
19+
20+
it('detects a single-object success response as accepted', () => {
21+
expect(isBatchAccepted(200, { jsonrpc: '2.0', id: 1, result: {} })).toBe(
22+
true
23+
);
24+
});
25+
26+
it('detects HTTP 4xx JSON-RPC errors as rejected', () => {
27+
expect(
28+
isBatchRejected(400, {
29+
jsonrpc: '2.0',
30+
id: null,
31+
error: { code: -32600, message: 'Invalid Request' }
32+
})
33+
).toBe(true);
34+
expect(
35+
isBatchRejected(400, {
36+
jsonrpc: '2.0',
37+
id: null,
38+
error: { code: -32000, message: 'Invalid or missing session ID' }
39+
})
40+
).toBe(true);
41+
});
42+
43+
it('does not treat HTTP 5xx as batch rejection', () => {
44+
expect(
45+
isBatchRejected(500, {
46+
jsonrpc: '2.0',
47+
id: null,
48+
error: { code: -32603, message: 'Internal error' }
49+
})
50+
).toBe(false);
51+
});
52+
53+
it('extracts JSON-RPC error codes from single-object bodies', () => {
54+
expect(
55+
jsonRpcErrorCode({
56+
jsonrpc: '2.0',
57+
id: null,
58+
error: { code: -32600, message: 'Invalid Request' }
59+
})
60+
).toBe(-32600);
61+
expect(jsonRpcErrorCode([{ error: { code: -32600 } }])).toBeUndefined();
62+
});
63+
});

0 commit comments

Comments
 (0)