Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions examples/servers/typescript/accepts-json-rpc-batch.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}) {
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<string, unknown>;
})
: { 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`
);
});
19 changes: 19 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand Down
3 changes: 3 additions & 0 deletions src/scenarios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
Expand Down
63 changes: 63 additions & 0 deletions src/scenarios/server/json-rpc-batch-rejection.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading