Skip to content

feat(transport): cap agent response size at 4 MiB by default - #40

Merged
bokelley merged 3 commits into
mainfrom
conductor/response-size-cap-java
Oct 16, 2026
Merged

bokelley merged 3 commits into
mainfrom
conductor/response-size-cap-java

Conversation

@bokelley

@bokelley bokelley commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

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_products with 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 through JdkA2AHttpClient, and none of them exposes 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.

What it does

Default 4 MiB, on without configuration, enforced while the body streams in:

  • Pre-reject on Content-Length — a declared length over the cap fails before a byte is read.
  • Count in onNext and cancel the subscription the moment the cap is crossed, so an oversized body is never buffered.
  • SSE per event and cumulatively per call, so Content-Type: text/event-stream buys 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's fromLineSubscriber splits on.
  • Force Accept-Encoding: identity so 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.

AdcpClient.builder().agent(agent).maxResponseBytes(32L * 1024 * 1024).build();
AgentConfig.builder().id("big-catalogue").agentUri(uri).maxResponseBytes(32L * 1024 * 1024).build();
client.callTool("get_products", args, GetProductsResponse.class,
        CallToolOptions.builder().maxResponseBytes(32L * 1024 * 1024).build());

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 GET that 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 gets maxResponseBytes × 256 cumulatively, 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's Content-Type, and "unbounded" must not be a choice an attacker gets to make.

Also in here

  • CallToolOptions.maxResponseBytes is now enforced (was "reserved, not yet enforced"). timeout remains reserved.
  • Agent-card fetches moved from the 4 KiB discovery-probe cap to the 4 MiB call cap and now throw rather than silently truncating — a card over 4 KiB used to be swapped for a synthetic fallback, which is easy to hit with a handful of skills.
  • Agent-card fetches got a body read deadline. HttpRequest.timeout covers 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 at readTimeout, which unblocks even a fully stalled read.
  • ResponseTooLargeError redacts 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.
  • Connection caches are keyed by the cap, so an override can't reuse a connection built for a different one. The A2A transport's JDK HTTP clients are reference-counted by the clients using them — neither JdkA2AHttpClient nor Client.close() releases them, and each owns a selector thread.
  • Fixed the class-doc claim that every outbound call routes through 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 in specs/response-size-limits.md and ROADMAP D24. AgentConfig gains a record component, so positional canonical-constructor calls need one more argument — the builder is unaffected.

Testing

./gradlew build green. New tests drive a real loopback server through a real JDK HttpClient, 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 no Content-Length, over-cap Content-Length pre-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 through ProtocolClient, 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-reviewer and security-reviewer roles; all findings are fixed in ec64b19 (the second commit) rather than deferred.

🤖 Generated with Claude Code


Open workspace in Conductor

bokelley and others added 2 commits October 14, 2026 02:19
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>
@bokelley
bokelley requested a review from MichielDean as a code owner October 14, 2026 02:44
@aao-ipr-bot

aao-ipr-bot Bot commented Oct 14, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

1 similar comment
@aao-ipr-bot

aao-ipr-bot Bot commented Oct 14, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

Copy link
Copy Markdown
Contributor Author

Argus couldn't post a review on either attempt, and the cause is in the action rather than in this PR — claude-code-action crashed while constructing its Anthropic client (SDK execution error: Error: Claude Code process exited with code 1, plus an Internal error: directory mismatch for ... tsconfig.json) before it ever read the diff. I re-ran it once; same failure. Worth a separate issue against the workflow.

So that a human reviewer isn't starting cold: this diff was reviewed before it was opened by the repo's own code-reviewer and security-reviewer roles. Between them they found six real problems, all fixed in ec64b19 rather than deferred:

  1. 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; the framing tests are parameterised over LF, CR and CRLF, and the CR case fails against the old ordering.
  2. The per-agent and per-call cap never reached the A2A agent-card fetch — an agent configured for 32 MiB still had its connection fail on a 5 MiB card, because getForAgentCard was hard-wired to the client-level cap. The override now threads through AgentCardLoader.
  3. HTTP client leak. 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 reference-counted now; that also removes the check-then-act race on the old bound.
  4. A slow-loris regression this PR introduced. HttpRequest.timeout only covers the status line and headers — the JDK fires its timer on the response stage, not the body. Moving card fetches from the 4 KiB probe cap to the 4 MiB call cap therefore widened the drip-feed window a thousandfold. The body read has its own deadline now, with a watchdog that closes the stream so even a fully stalled read unblocks.
  5. The GET-SSE cumulative exemption was driven by peer-controlled input. A peer answering any GET with Content-Type: text/event-stream turned the cumulative cap off for itself. Long-lived listening streams still get a bigger budget than one call, but it's finite: 256× the per-event cap.
  6. 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. Redacted now; url() still returns what was configured. The MCP side also interpolated raw peer-supplied exception text into logs and ProtocolError messages while A2A sanitised; both do now.

Things worth a second pair of eyes, since the automated reviewer didn't get there:

  • The listening-stream asymmetry (ResponseSizeLimitingHttpClient class javadoc, specs/response-size-limits.md). A GET answered with text/event-stream gets 256× the cumulative budget, per-event cap unchanged. The reasoning is that per-event is what bounds memory — the transports aggregate one event at a time — while cumulative capping is about bounding a single call, and a session channel isn't a call. If you'd rather have one uniform number, this is the place to say so.
  • AgentConfig gains a record component, so positional canonical-constructor calls need one more argument. The builder is unaffected and nothing in-repo used the canonical constructor directly.
  • Agent cards over 4 KiB now behave differently. They used to be silently truncated and swapped for a synthetic fallback card; they're now read whole. Anyone depending on the fallback's shape will see the agent's real card instead.

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>
@aao-ipr-bot

aao-ipr-bot Bot commented Oct 14, 2026

Copy link
Copy Markdown

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@bokelley
bokelley merged commit f8e9ef0 into main Oct 16, 2026
8 checks passed
@bokelley
bokelley deleted the conductor/response-size-cap-java branch October 16, 2026 09:00
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.

Cap agent response size by default to prevent memory DoS

1 participant