Skip to content

fix(jsonrpc): accept batches only for protocol 2025-03-26 (#319) - #324

Open
mcollina wants to merge 1 commit into
mainfrom
fix/319-version-aware-batching
Open

mcollina wants to merge 1 commit into
mainfrom
fix/319-version-aware-batching

Conversation

@mcollina

Copy link
Copy Markdown
Member

Problem

Batch handling ignored the negotiated protocol version, and the two transports disagreed:

  • HTTP refused every batch, including from clients that negotiated 2025-03-26, the revision that spelled batching out.
  • stdio split batches itself and accepted them even after the client negotiated 2025-06-18 or later, which removed batching.

Fix

Both transports now decide from the negotiated revision. I went with the version-aware option from the issue, since it fit cleanly into the existing per-request version resolution.

2024-11-05, 2025-03-26 2025-06-18, 2025-11-25
Array body array of responses single -32600, id: null
Notifications / client responses in the batch no entry refused
Only notifications / responses HTTP 202, no body; stdio writes nothing refused
Empty array single -32600 refused
Invalid entry (1, {}, unsafe numeric id) -32600 for that entry only refused
initialize inside a batch -32600 for that entry (2025-03-26 lifecycle: MUST NOT be batched) refused

Design decisions

  • One implementation. processMessage in src/handlers.ts handles arrays through a new processBatch. The predicate is supportsBatching() in src/protocol-version.ts, next to the other revision helpers. stdio no longer splits batches. It forwards the batch line to /mcp in one inject, carrying the version negotiated at initialize in MCP-Protocol-Version (the same as single messages), so both transports behave the same by construction.
  • Version source on HTTP is the one the route already resolves: the session's negotiated version when there is a session, otherwise the MCP-Protocol-Version header, otherwise the spec's 2025-03-26 default for an absent header. So a header-less, session-less batch is accepted, as the spec's default implies. A session negotiated at 2025-06-18 refuses batches even without a header.
  • initialize in a batch is refused for that entry. isInitializeRequest no longer looks inside arrays, so a batched initialize cannot open a session. It also cannot exempt the batch from the session/header version reconciliation, which would otherwise let a 2025-06-18 session opt back into batching with a 2025-03-26 header.
  • 2024-11-05 accepts batches. That revision is plain JSON-RPC 2.0, which has always defined batches. Only 2025-06-18 took them out.
  • Concurrent entries (Promise.all, which JSON-RPC allows). One long-running entry, such as a blocking tasks/result, does not hold up the others, and response order follows request order. All entries share the one HTTP reply, and nothing in the request handlers hijacks or streams it, so this is safe.
  • The raw stdio line is forwarded, not the re-serialized array. Re-serializing would turn an id like 1e400 into null before the per-entry safe-integer check could refuse it.
  • A refused batch still returns HTTP 200 with a JSON-RPC error body, as before. Only when it is refused has changed.

Tests

  • New test/jsonrpc-batch.test.ts (22 tests): HTTP with each version via header, via session, and with no header; notifications-only, empty, invalid entries, unsafe ids, initialize in a batch, and the version-override attempt. stdio after negotiating each version, notifications-only, initialize in a batch, and 1e400 ids.
  • Updated two existing tests that encoded the old behavior. test/index.test.ts now sends MCP-Protocol-Version: 2025-06-18 to test the refusal. test/stdio.test.ts now expects a batch after 2025-11-25 to be refused.
  • README: documented batching per revision under Protocol Version Negotiation and updated the stdio feature bullets.

npm run lint, npm run typecheck and npm run test all pass: 788/788 with Redis.

Fixes #319

🤖 Generated with Claude Code

https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

Batch handling ignored the negotiated protocol version, and the two
transports disagreed: HTTP refused every batch, even from clients on
2025-03-26 (the revision that spelled batching out), while stdio accepted
batches after 2025-06-18 had removed them.

Both transports now decide from the negotiated revision:

- 2024-11-05 and 2025-03-26: batches are processed per JSON-RPC 2.0.
  Responses come back as an array; notifications and client responses
  add no entry; an all-notification batch gets 202 (stdio writes
  nothing); an empty batch is a single -32600; each invalid entry gets
  its own -32600; initialize inside a batch is refused for that entry,
  as 2025-03-26 forbids batching it. Entries run concurrently.
- 2025-06-18 and later: a batch is refused with a single -32600.

On HTTP the version is the one already resolved for the request (the
session's negotiated version, else MCP-Protocol-Version, else the
2025-03-26 default). An initialize inside a batch no longer opens a
session or exempts the batch from the session/header version check.

stdio now forwards a batch line to the HTTP handler whole, with the
version negotiated at initialize, instead of splitting it itself, so
both transports share one implementation. The raw line is forwarded so
an id like 1e400 is still refused rather than re-serialized to null.

Fixes #319

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LmhsFjk8W96X2PvmShmDm9

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON-RPC batch handling ignores the negotiated protocol version

1 participant