Repository navigation
feat(transport): cap agent response size at 4 MiB by default - #40
Conversation
Agent responses are untrusted input. Until now the only limit on the MCP and A2A call paths was a 10 MB character check that ran after the whole body had been buffered and parsed — far too late to stop the memory exhaustion it was meant to prevent. One agent answering get_products with a 17.8 MB payload was enough to OOM-kill a hosted grader at 10+ GB RSS. The SDK does not own the sockets on those paths: MCP goes through HttpClientStreamableHttpTransport / HttpClientSseClientTransport and A2A through JdkA2AHttpClient, none of which expose a body-size hook. What they do accept is a JDK HttpClient, so ResponseSizeLimitingHttpClient decorates one and enforces the cap where the bytes actually arrive: - pre-reject on a declared Content-Length over the cap, before a byte is read - count bytes in onNext and cancel the subscription the moment the cap is crossed, so an oversized body is never buffered - SSE capped per event and cumulatively per call, so Content-Type: text/event-stream buys a peer no extra budget - force Accept-Encoding: identity so the counted bytes are the bytes held Default 4 MiB, overridable per client, per agent and per call; every setter rejects a non-positive value, so the cap can be raised but not switched off. Over-cap responses throw ResponseTooLargeError rather than truncating, and ResponseTooLargeError.findIn() recovers it from the cause chain the JDK and the transports wrap it in — which is also how the evict-and-retry path knows not to re-download an oversized payload as a transient I/O blip. CallToolOptions.maxResponseBytes is now enforced instead of reserved, and agent-card fetches move from the 4 KiB discovery-probe cap to the 4 MiB call cap: a card over 4 KiB used to be silently truncated and swapped for a synthetic fallback. Connection caches are keyed by the cap so an override cannot reuse a connection built for a different one. Closes #39 Cross-SDK contract: adcontextprotocol/adcp#8190 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six fixes from the code and security review of the cap: SSE bare-CR blank lines never reset the per-event counter. The pending CR's terminator was counted and then zeroed by the same byte's content reset before the blank-line test ran, so a CR-framed peer's stream accumulated forever and died on a bogus ResponseTooLargeError. CR is a legal SSE terminator and one of the three the JDK's fromLineSubscriber splits on. The boundary test now runs before the content reset, and the per-terminator tests are parameterised over LF, CR and CRLF. The per-agent and per-call cap never reached the A2A agent-card fetch, so an agent configured for 32 MiB still had its connection fail on a 5 MiB card. The cap now threads through AgentCardLoader into getForAgentCard. Past eight distinct caps the A2A factory built a fresh JDK HttpClient per call and never closed it — neither JdkA2AHttpClient nor Client.close() owns it, and each holds a selector thread. The pool is now reference-counted by the clients using it: same cap shares one, last one evicted shuts it down. That also removes the check-then-act race on the old bound. Moving agent-card fetches to the 4 MiB cap widened the slow-loris window a thousandfold: HttpRequest.timeout only covers the status line and headers, so a peer could send headers and then drip, pinning a thread and socket for weeks. The body read now has its own deadline — a watchdog closes the stream, which unblocks even a fully stalled read. A GET answered with text/event-stream had no cumulative cap at all, and that branch is chosen by the peer's own Content-Type. Long-lived listening streams still need a bigger budget than one call, but it is now finite: 256x the per-event cap. ResponseTooLargeError put the raw agent URI in its message, userinfo and query included — a message that gets logged, shown to users and fed to LLMs. It is redacted now; url() still returns what was configured. The MCP side also interpolated raw peer-supplied exception text into log lines and ProtocolError messages while the A2A side sanitised; both do now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
1 similar comment
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
|
Argus couldn't post a review on either attempt, and the cause is in the action rather than in this PR — So that a human reviewer isn't starting cold: this diff was reviewed before it was opened by the repo's own
Things worth a second pair of eyes, since the automated reviewer didn't get there:
Every enforcement path was mutation-checked: disabling the cap, the strict agent-card path, the SSE boundary ordering, or the read deadline makes the corresponding tests fail or hang. |
The comment above the listening-stream branch still said "cap each event but not the running total", which stopped being true when that branch gained a finite 256x ceiling. Also spells out why a declared Content-Length is held to the per-call cap even on a GET event stream — a body of known, finite size is a payload, not a listening stream, so it gets no share of the larger ceiling. That asymmetry was deliberate but read as accidental. Log lines in the decorator now go through the same URI redaction the error message uses; they were printing userinfo and query strings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final This is an automated message from the Argus AI review workflow. |
Closes #39. Cross-SDK contract: adcontextprotocol/adcp#8190
The problem
Agent responses are untrusted input. The only limit on the MCP and A2A call paths was a 10 MB character check that ran after the whole body had been buffered and parsed — far too late to stop the memory exhaustion it was meant to prevent. One agent answering
get_productswith a 17.8 MB payload was enough to OOM-kill a hosted grader at 10+ GB RSS, starving every other agent in the batch.The Java-specific wrinkle: the SDK doesn't own the sockets on those paths. MCP goes through
HttpClientStreamableHttpTransport/HttpClientSseClientTransport, A2A throughJdkA2AHttpClient, and none of them exposes a body-size hook. What they do accept is a JDKHttpClient— soResponseSizeLimitingHttpClientdecorates one and enforces the cap where the bytes actually arrive.What it does
Default 4 MiB, on without configuration, enforced while the body streams in:
Content-Length— a declared length over the cap fails before a byte is read.onNextand cancel the subscription the moment the cap is crossed, so an oversized body is never buffered.Content-Type: text/event-streambuys no extra budget and neither does drip-feeding small events. Event framing accepts CR, LF and CRLF — all three are legal SSE terminators and all three are what the JDK'sfromLineSubscribersplits on.Accept-Encoding: identityso counted bytes are held bytes. (The JDK never decompresses transparently, so a peer can't hide 1 GiB behind a small gzip stream.)Overridable per client, per agent and per call — resolution order call → agent → client. Every setter rejects a non-positive value: the cap can be raised, not switched off.
Over-cap responses throw
ResponseTooLargeError(RESPONSE_TOO_LARGE) — never truncated. The cap fires inside a JDK body subscriber, so the error arrives wrapped;ResponseTooLargeError.findIn(throwable)recovers it. That's also how the evict-and-retry path knows not to re-download an oversized payload as a transient I/O blip.One deliberate asymmetry
The MCP SSE session channel is a
GETthat stays open for the life of the connection and carries every server-to-client message. Holding its running total to one call's cap would kill healthy long sessions, so it getsmaxResponseBytes × 256cumulatively, per-event cap unchanged. Per-event is the cap that bounds memory there — the transports aggregate one event at a time and discard the bytes in between. The multiplier is finite on purpose: which branch a response takes depends partly on the peer'sContent-Type, and "unbounded" must not be a choice an attacker gets to make.Also in here
CallToolOptions.maxResponseBytesis now enforced (was "reserved, not yet enforced").timeoutremains reserved.HttpRequest.timeoutcovers only the status line and headers, so without one, moving to the bigger cap would have widened the slow-loris window a thousandfold. A watchdog closes the stream atreadTimeout, which unblocks even a fully stalled read.ResponseTooLargeErrorredacts userinfo and query from the URI in its message;url()still returns what was configured. The MCP side now sanitises peer-supplied exception text in logs and error messages, matching what A2A already did.JdkA2AHttpClientnorClient.close()releases them, and each owns a selector thread.AdcpHttpClient; it says what actually covers the call transports now.Behaviour change for adopters
Released as a minor. A call that used to return a 6 MB catalogue now throws. Migration guidance, the error, and the overrides are in
docs/response-size-limits.md; the design and the cross-SDK contract are inspecs/response-size-limits.mdand ROADMAP D24.AgentConfiggains a record component, so positional canonical-constructor calls need one more argument — the builder is unaffected.Testing
./gradlew buildgreen. New tests drive a real loopback server through a real JDKHttpClient, because a check that only runs after the body is buffered passes a unit test and still OOMs the caller. Covered: over-cap body with noContent-Length, over-capContent-Lengthpre-reject, oversized single SSE event, cumulative SSE over cap, SSE content-type as an escape hatch, under-cap and exactly-at-cap success, per-call override, async sends, identity encoding (and that the request rewrite preserves method, body and headers), event framing across LF/CR/CRLF, the finite listening-stream ceiling, agent-card strict cap and per-fetch override, drip-feed and full-stall read deadlines, URI redaction, cap resolution order throughProtocolClient, cache keying by cap, and HTTP client release on eviction.Every enforcement test was mutation-checked: disabling the cap, the strict agent-card path, the SSE boundary ordering, or the read deadline makes the corresponding tests fail or hang.
Reviewed before opening by the repo's
code-reviewerandsecurity-reviewerroles; all findings are fixed in ec64b19 (the second commit) rather than deferred.🤖 Generated with Claude Code
Open workspace in Conductor