JSON-RPC batch handling across transports
June 14, 2026 · View on GitHub
This note records how AgentFence treats JSON-RPC batch (array) request bodies
on each transport, why the default is reject-by-default, and the migration
implications. It grounds the gating behavior shipped in the HTTP proxy
(--on-batch, --on-unparsed).
What a batch is
JSON-RPC 2.0 §6 allows a client to send an array of request objects in a
single payload; the server replies with an array of responses. MCP layers on
top of JSON-RPC, so a batch can in principle carry several tools/call
requests at once.
Observed behavior of the two proxies (before this change)
Both proxies modeled only a single request envelope (internal/mcp/model.go),
so a batch slipped past the gate:
- HTTP proxy (
internal/httpproxy):ServeHTTPparsed onemcp.ParseRequest(body). An array failed that parse and fell through to a transparentforward(...)— ungated. - stdio proxy (
internal/proxy):processAgentLineparses one JSON-RPC request per line. A single line holding an array also failsParseRequestand is forwarded untouched — ungated.
For a security gate, "a documented hole is still a hole": an agent (or an
injected instruction) could wrap a denied tools/call in a one-element array
to bypass policy.
Do MCP clients actually send batches?
Batch support across MCP clients is inconsistent, and the dominant pattern is one request per message. The 2025 MCP specification revision moved to remove JSON-RPC batching from the protocol, so batches are at best a legacy edge and at worst an attack vector — not a path worth optimizing for. This evidence points to reject-by-default as the safe, low-regret choice rather than building full batch fan-out.
Decision
- HTTP proxy: reject batches by default (
--on-batch reject), returning a JSON-RPC error (ErrorCodeBatchNotGated). An operator who genuinely needs batching can opt into--on-batch evaluate, which evaluates everytools/callmember and forwards the whole batch only if all members are allowed (all-or-nothing). A single non-allow member rejects the entire batch, so a denied call can never ride along with allowed ones. Each member's decision is still audited. Note two consequences ofevaluate: a batch with severalask-tier members triggers one approval prompt per member, and because the batch is forwarded unchanged its response is not observed for taint tracking (seethreat-model.mdtaint limits). - stdio proxy: the line-framed relay already forwards a batch line
untouched. Because the per-line model never evaluates it, the safe posture is
the same reject-by-default intent; a batch line that fails single-request
parsing is non-
tools/calltraffic. Bringing an equivalent--on-batchswitch to the stdio relay is tracked as follow-up — the HTTP path is where batches arrive as a single inspectable body today.
Related, the HTTP proxy now also fails closed on oversize bodies (refused
rather than forwarded uninspected) and offers --on-unparsed reject for bodies
that are not valid JSON-RPC at all. Valid JSON-RPC methods other than
tools/call (initialize, ping, notifications) are always forwarded so the MCP
session keeps working.
Migration implications
Rejecting batches by default is a behavior change for any client that sent
batches and relied on transparent forwarding. Such clients will receive a
JSON-RPC error instructing them to send one request per body or to run the
proxy with --on-batch evaluate. Given that batching is being removed from MCP
and was previously ungated (and therefore unsafe to rely on), this is the
correct fail-closed default.