From 87fd8972c923f708539f1d8eef81a212816b77c6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 14 Oct 2026 02:19:28 +0000 Subject: [PATCH 1/3] feat(transport): cap agent response size at 4 MiB by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://github.com/adcontextprotocol/adcp/issues/8190 Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/cap-agent-response-size.md | 52 ++ ROADMAP.md | 3 +- .../adcontextprotocol/adcp/AdcpClient.java | 39 ++ .../adcontextprotocol/adcp/AgentConfig.java | 45 +- .../adcp/error/ResponseTooLargeError.java | 57 +- .../adcp/http/AdcpHttpClient.java | 222 ++++++- .../http/ResponseSizeLimitingHttpClient.java | 582 ++++++++++++++++++ .../adcp/transport/CallToolOptions.java | 33 +- .../adcp/transport/ProtocolClient.java | 63 +- .../adcp/transport/a2a/A2aCaller.java | 82 ++- .../transport/a2a/A2aConnectionManager.java | 157 ++++- .../adcp/transport/mcp/McpCaller.java | 45 +- .../transport/mcp/McpConnectionManager.java | 95 ++- .../adcp/AdcpClientTest.java | 22 + .../adcp/AgentConfigTest.java | 31 + .../adcp/http/AdcpHttpClientBodyCapTest.java | 207 +++++++ .../ResponseSizeLimitingHttpClientTest.java | 418 +++++++++++++ .../a2a/A2aConnectionManagerTest.java | 16 +- .../a2a/ResponseSizeCapWiringTest.java | 185 ++++++ .../mcp/McpConnectionManagerTest.java | 27 + specs/response-size-limits.md | 137 +++++ specs/ssrf-baseline.md | 4 +- 22 files changed, 2377 insertions(+), 145 deletions(-) create mode 100644 .changeset/cap-agent-response-size.md create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/http/AdcpHttpClientBodyCapTest.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClientTest.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/ResponseSizeCapWiringTest.java create mode 100644 specs/response-size-limits.md diff --git a/.changeset/cap-agent-response-size.md b/.changeset/cap-agent-response-size.md new file mode 100644 index 0000000..c90ddce --- /dev/null +++ b/.changeset/cap-agent-response-size.md @@ -0,0 +1,52 @@ +--- +"adcp": minor +--- + +feat(transport): cap agent response size at 4 MiB by default + +Agent responses are untrusted input. Every MCP and A2A call response — tool calls, `tools/list`, +SSE streams and agent-card fetches — is now capped at **4 MiB** by default, and the cap is enforced +while the body streams in: a declared `Content-Length` over the cap is rejected before a byte is +read, and a chunked body is aborted the moment the running count crosses it. SSE is capped per +event as well as cumulatively per call, so a peer cannot buy extra budget by answering with +`Content-Type: text/event-stream`. Outbound requests force `Accept-Encoding: identity` so the +counted bytes are the bytes you hold. + +Previously the only limit on the call paths was a 10 MB *character* check that ran after the whole +body had been buffered and parsed — far too late to stop a memory exhaustion. One agent answering +`get_products` with a 17.8 MB payload was enough to OOM-kill a hosted grader at 10+ GB RSS. + +**This is a behaviour change.** A call that used to return a 6 MB catalogue now throws +`ResponseTooLargeError` (`code() == "RESPONSE_TOO_LARGE"`). Raise the limit where you need it — +there is no opt-out, only a bigger number: + +```java +// per client +AdcpClient.builder().agent(agent).maxResponseBytes(32L * 1024 * 1024).build(); + +// per agent +AgentConfig.builder().id("big-catalogue").agentUri(uri) + .maxResponseBytes(32L * 1024 * 1024).build(); + +// per call +client.callTool("get_products", args, GetProductsResponse.class, + CallToolOptions.builder().maxResponseBytes(32L * 1024 * 1024).build()); +``` + +The cap is enforced inside a JDK body subscriber and may reach you wrapped, so branch on it with +`ResponseTooLargeError.findIn(throwable)`. + +Also in this change: + +- `CallToolOptions.maxResponseBytes` is now enforced (it was documented as "reserved, not yet + enforced"). `CallToolOptions.timeout` remains reserved. +- `AgentConfig` gains a `maxResponseBytes` component, and `AdcpClient.Builder` / + `AgentConfig.Builder` gain `maxResponseBytes(long)`. Code that calls the `AgentConfig` canonical + constructor positionally needs one more argument; the builder is unaffected. +- Agent-card fetches moved from the 4 KiB discovery-probe cap to the 4 MiB call cap and now throw + instead of silently truncating — a card over 4 KiB used to be swapped for a synthetic fallback. +- MCP and A2A connection caches are keyed by the response cap, so a call that overrides it does not + reuse a connection built for a different one. + +Defaults, overrides and the SSE rules are documented in `specs/response-size-limits.md`. +Cross-SDK contract: https://github.com/adcontextprotocol/adcp/issues/8190 diff --git a/ROADMAP.md b/ROADMAP.md index c1e57e0..8aa468f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,7 @@ Decisions made post-RFC that supersede or refine the merged text. The table belo | D21 | Branch protection on `main` | **Required: 1 code-owner approving review, required CI checks (`build`, `test`, `storyboard`, `IPR Policy / Signature`), no force-push, no direct push, no admin bypass.** Dependabot patch PRs auto-merge after green CI. Two-reviewer gate doesn't add safety with a founder pair; revisit when contributor base grows. | (Sets a repo convention) | | D22 | Multi-tenant signing keys | **Signing key selection is tenant-aware, not just `adcp_use`-aware.** The L1 signing SPI must select by purpose plus caller/resolved publisher tenant context, a JOSE-compatible pattern where one operator can publish multiple tenant keys on a single JWKS endpoint: same `adcp_use`, distinct `kid` per tenant. v0.2 freezes the explicit parameter set for `SigningContext` — `AdcpUse use()`, nullable tenant identity, nullable principal reference — and the shipped API is `SigningContext`-based with no single-arg `SigningProvider.forUse(AdcpUse)` short form. v0.3 wires tenant resolution from `AccountStore` / `adagents.json` into that selector. Longer-form shape lives in [`specs/signing-context.md`](specs/signing-context.md). | Refines RFC §L1 signing and §L2 multi-tenant principal resolution | | D23 | AdCP 3.1 beta parity target | **Track the signed protocol bundle as the source of truth; use TS/Python as implementation references.** For this roadmap update the pinned target is `3.1.0-beta.5` (2026-05-26). If 3.1 GA cuts before the v0.1 codegen PR, bump to GA deliberately in that PR; otherwise beta.5 is the target. `@adcp/sdk@8.1.0-beta.13` is aligned to beta.5; Python `6.3.0b4` is on beta.4 and is a reference, not a blocker. Java's local `ADCP_VERSION` remains `3.0.11`; v0.1 must include wire-version negotiation, multi-bundle schema loading, and 3.1 beta/GA codegen coverage before claiming cross-SDK parity. | Updates RFC parity baseline and D15 spec-rev SLO | +| D24 | Agent response size cap | **Agent responses are capped by default at 4 MiB, enforced while the body streams in, throwing `ResponseTooLargeError`.** Covers MCP `callTool`, A2A `message/send`, SSE (per event and cumulatively per call), `tools/list` and agent-card fetches. Overridable per client, per agent and per call; never disablable. Enforced by wrapping the JDK `HttpClient` handed to the third-party MCP/A2A transports, which expose no body-size hook of their own. Discovery probes keep their separate 4 KiB cap from [`specs/ssrf-baseline.md`](specs/ssrf-baseline.md). Full shape in [`specs/response-size-limits.md`](specs/response-size-limits.md); cross-SDK contract in [adcp#8190](https://github.com/adcontextprotocol/adcp/issues/8190). | Extends RFC §SSRF baseline body cap from probes to call paths | ## Parity baseline (as of 2026-05-26) @@ -587,7 +588,7 @@ Additional decisions added post-RFC that remain open: | RFC merged on adcontextprotocol/adcp | ✅ (PR #4279, 2026-05-13) | | RFC imported to this repo | ✅ ([docs/rfc/java-sdk-rfc.md](docs/rfc/java-sdk-rfc.md)) | | Implementation plan drafted | ✅ (this doc) | -| Confirmed decisions | ✅ D1–D21 locked; D22 + D23 framing locked, shape/value details captured in this roadmap and linked specs. | +| Confirmed decisions | ✅ D1–D21 locked; D22 + D23 framing locked, D24 shipped, shape/value details captured in this roadmap and linked specs. | | Protocol parity check | 🟡 Behind — local Java pin is `3.0.11`; latest upstream check is AdCP `3.1.0-beta.5`, TS `@adcp/sdk@8.1.0-beta.13`, Python `adcp@6.3.0b4` on `3.1.0-beta.4`. | | Funding / staffing confirmed | ⏳ Decision pending | | Tracks claimed | 3 / 14 — `infra` (Track 1, #2), `codegen` (Track 2, #11), `transport` (Track 3, #17) | diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java index 02e52df..73aff60 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java @@ -35,6 +35,15 @@ *

Named convenience methods (e.g. {@code getProducts()}) are provided * for each tool in the TS SDK parity target. All funnel through * {@link #callTool(String, Map, Class, CallToolOptions)}. + * + *

Response size cap. Every agent response is capped at 4 MiB by + * default and the cap is enforced while the body streams in; an over-cap + * response throws + * {@link org.adcontextprotocol.adcp.error.ResponseTooLargeError}. Raise it with + * {@link Builder#maxResponseBytes(long)} (per client), + * {@link AgentConfig.Builder#maxResponseBytes(long)} (per agent) or + * {@link CallToolOptions.Builder#maxResponseBytes(long)} (per call). See + * {@code specs/response-size-limits.md}. */ public final class AdcpClient implements AutoCloseable { @@ -63,6 +72,7 @@ private AdcpClient(Builder builder) { this.adcpHttpClient = AdcpHttpClient.builder() .ssrfPolicy(ssrfPolicy) + .maxCallResponseBytes(builder.maxResponseBytes) .build(); McpConnectionManager mcpConnectionManager = new McpConnectionManager( Duration.ofSeconds(10), builder.requestTimeout, adcpHttpClient); @@ -174,6 +184,7 @@ public static final class Builder { private @Nullable ObjectMapper objectMapper; private @Nullable SsrfPolicy ssrfPolicy; private Duration requestTimeout = Duration.ofSeconds(30); + private long maxResponseBytes = AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES; private Builder() {} @@ -225,6 +236,34 @@ public Builder requestTimeout(Duration requestTimeout) { return this; } + /** + * Caps every agent response at {@code maxResponseBytes}. Default 4 MiB + * ({@link AdcpHttpClient#DEFAULT_MAX_CALL_RESPONSE_BYTES}). + * + *

The cap covers MCP tool calls, A2A {@code message/send}, SSE + * streams and agent-card fetches, and is enforced while the body + * streams in: an over-cap response throws + * {@link org.adcontextprotocol.adcp.error.ResponseTooLargeError} and is + * never buffered in full. Agent responses are untrusted input, so the + * cap cannot be disabled — only raised. + * + *

A single agent can override this via + * {@link AgentConfig.Builder#maxResponseBytes(long)}, and a single call + * via + * {@link org.adcontextprotocol.adcp.transport.CallToolOptions.Builder#maxResponseBytes(long)}. + * + * @param maxResponseBytes cap in bytes; must be positive + */ + public Builder maxResponseBytes(long maxResponseBytes) { + if (maxResponseBytes <= 0) { + throw new ConfigurationError( + "maxResponseBytes must be positive: " + maxResponseBytes, + "maxResponseBytes"); + } + this.maxResponseBytes = maxResponseBytes; + return this; + } + /** Builds the client. */ public AdcpClient build() { validateAdcpVersion(adcpVersion); diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/AgentConfig.java b/adcp/src/main/java/org/adcontextprotocol/adcp/AgentConfig.java index 0edd865..8bb1c03 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/AgentConfig.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/AgentConfig.java @@ -26,6 +26,10 @@ *

  • OAuth auth-code ({@code oauthTokens})
  • * * + *

    Responses from this agent are capped at + * {@link #maxResponseBytes()} bytes (4 MiB by default) and the cap is enforced + * while the body streams in — see {@code specs/response-size-limits.md}. + * * Use {@link #builder()} to construct instances. */ public record AgentConfig( @@ -39,7 +43,8 @@ public record AgentConfig( @Nullable String webhookUrlTemplate, @Nullable String webhookSecret, @Nullable AdcpVersion adcpVersion, - Map extraHeaders + Map extraHeaders, + @Nullable Long maxResponseBytes ) { private static final Logger log = LoggerFactory.getLogger(AgentConfig.class); @@ -52,6 +57,7 @@ public record AgentConfig( validateAuth(authToken, basicAuth, oauthClientCredentials, oauthTokens); validateAuthToken(authToken); validateExtraHeaders(extraHeaders); + validateMaxResponseBytes(maxResponseBytes); warnPlaintextAuth(agentUri, authToken, basicAuth, oauthClientCredentials, oauthTokens); } @@ -68,7 +74,8 @@ public String toString() { + ", webhookSecret=" + (webhookSecret != null ? "" : "null") + ", adcpVersion=" + adcpVersion + ", extraHeaders=" + (extraHeaders.isEmpty() - ? "{}" : "<" + extraHeaders.size() + " headers>") + "]"; + ? "{}" : "<" + extraHeaders.size() + " headers>") + + ", maxResponseBytes=" + maxResponseBytes + "]"; } /** Creates a builder for {@code AgentConfig}. */ @@ -137,6 +144,14 @@ private static boolean hasCrlf(String s) { return s.indexOf('\r') >= 0 || s.indexOf('\n') >= 0; } + private static void validateMaxResponseBytes(@Nullable Long maxResponseBytes) { + if (maxResponseBytes != null && maxResponseBytes <= 0) { + throw new ConfigurationError( + "maxResponseBytes must be positive: " + maxResponseBytes, + "maxResponseBytes"); + } + } + private static void warnPlaintextAuth( URI agentUri, @Nullable String authToken, @@ -166,6 +181,7 @@ public static final class Builder { private @Nullable String webhookSecret; private @Nullable AdcpVersion adcpVersion; private Map extraHeaders = Map.of(); + private @Nullable Long maxResponseBytes; private Builder() {} @@ -246,6 +262,29 @@ public Builder extraHeaders(Map extraHeaders) { return this; } + /** + * Caps this agent's responses at {@code maxResponseBytes}, overriding + * the client default (4 MiB, see + * {@link org.adcontextprotocol.adcp.http.AdcpHttpClient#DEFAULT_MAX_CALL_RESPONSE_BYTES}). + * + *

    Raise it for an agent that legitimately publishes a very large + * catalogue. There is no way to disable the cap: agent responses are + * untrusted input, and an uncapped read is how one peer memory-DoSes + * the caller. A single call can override this again via + * {@link org.adcontextprotocol.adcp.transport.CallToolOptions#maxResponseBytes()}. + * + * @param maxResponseBytes cap in bytes; must be positive + */ + public Builder maxResponseBytes(long maxResponseBytes) { + if (maxResponseBytes <= 0) { + throw new ConfigurationError( + "maxResponseBytes must be positive: " + maxResponseBytes, + "maxResponseBytes"); + } + this.maxResponseBytes = maxResponseBytes; + return this; + } + /** Builds the config, validating required fields and auth exclusivity. */ public AgentConfig build() { if (id == null) { @@ -258,7 +297,7 @@ public AgentConfig build() { id, agentUri, protocol, authToken, basicAuth, oauthClientCredentials, oauthTokens, webhookUrlTemplate, webhookSecret, adcpVersion, - extraHeaders); + extraHeaders, maxResponseBytes); } } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/error/ResponseTooLargeError.java b/adcp/src/main/java/org/adcontextprotocol/adcp/error/ResponseTooLargeError.java index c5441d0..ca6f22e 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/error/ResponseTooLargeError.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/error/ResponseTooLargeError.java @@ -4,12 +4,27 @@ import java.net.URI; -/** Response body exceeded the configured maximum size. */ +/** + * Response body exceeded the configured maximum size. + * + *

    Thrown by the AdCP transports when an agent answers with more bytes than + * the configured cap allows. The cap is on by default (4 MiB) and is enforced + * while the body streams in, so an oversized response never reaches the heap + * in full. See {@code specs/response-size-limits.md}. + * + *

    Raise the cap per client + * ({@code AdcpClient.builder().maxResponseBytes(...)}), per agent + * ({@code AgentConfig.builder().maxResponseBytes(...)}) or per call + * ({@code CallToolOptions.builder().maxResponseBytes(...)}). + */ public final class ResponseTooLargeError extends AdcpError { @java.io.Serial private static final long serialVersionUID = 1L; + /** Depth bound for {@link #findIn} so a self-referential cause chain cannot loop. */ + private static final int MAX_CAUSE_DEPTH = 32; + private final long limit; private final long bytesRead; private final @Nullable URI url; @@ -24,14 +39,54 @@ public ResponseTooLargeError(long limit, long bytesRead, @Nullable URI url) { this.url = url; } + /** + * Finds a {@code ResponseTooLargeError} in a throwable's cause chain. + * + *

    The cap is enforced inside a {@code java.net.http} body subscriber, and + * the JDK and the MCP/A2A transports each wrap subscriber failures before + * they surface. This walks the chain so callers (and the SDK's own transport + * code, which must not retry an oversized response) can recover the typed + * error. + * + * @param throwable the throwable to inspect, may be {@code null} + * @return the first {@code ResponseTooLargeError} found, or {@code null} + */ + public static @Nullable ResponseTooLargeError findIn(@Nullable Throwable throwable) { + Throwable current = throwable; + for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (current instanceof ResponseTooLargeError error) { + return error; + } + for (Throwable suppressed : current.getSuppressed()) { + if (suppressed instanceof ResponseTooLargeError error) { + return error; + } + } + Throwable cause = current.getCause(); + if (cause == current) { + break; + } + current = cause; + } + return null; + } + + /** The configured cap, in bytes. */ public long limit() { return limit; } + /** + * Bytes counted when the cap was breached — the declared + * {@code Content-Length} for a pre-rejected response, otherwise the running + * total at the moment the read was aborted. Not the full body size: the SDK + * stops reading rather than measure an oversized payload. + */ public long bytesRead() { return bytesRead; } + /** The URL that produced the oversized response, if known. */ public @Nullable URI url() { return url; } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/http/AdcpHttpClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/http/AdcpHttpClient.java index b9176a2..66e43af 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/http/AdcpHttpClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/http/AdcpHttpClient.java @@ -1,5 +1,6 @@ package org.adcontextprotocol.adcp.http; +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,11 +25,38 @@ *

  • Resolve DNS once, validate the full address set
  • *
  • Validate resolved addresses against the SSRF policy
  • *
  • {@code redirect: manual} (no transparent redirect-follow)
  • - *
  • Body cap (default 4 KiB for probes, configurable per call)
  • + *
  • Body cap — two of them, for two different jobs (see below)
  • * * - *

    Every outbound HTTP call in the SDK routes through this client. - * Built on {@link java.net.http.HttpClient} (JDK 21). + *

    Two caps

    + * + * + *

    Outbound coverage. Discovery and agent-card requests are sent by + * this client directly. The MCP + * ({@code HttpClientStreamableHttpTransport} / {@code HttpClientSseClientTransport}) + * and A2A ({@code JdkA2AHttpClient}) call transports own their own sockets, so + * they do not route through {@link #send}; they are instead built on a + * {@link java.net.http.HttpClient} handed to them by + * {@link #newHttpClientBuilder()}, which carries this client's connect timeout, + * {@code followRedirects(NEVER)} policy and call-response cap. SSRF address + * validation for those paths happens in + * {@link org.adcontextprotocol.adcp.transport.ProtocolClient} before the call, + * not inside the transport. + * + *

    Built on {@link java.net.http.HttpClient} (JDK 21). * *

    The client keeps the original URI authority unchanged so HTTPS uses the * intended hostname for TLS SNI and hostname verification. DNS validation @@ -45,12 +73,27 @@ public final class AdcpHttpClient implements AutoCloseable { /** Default body cap for discovery probes (4 KiB). */ public static final long DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024; + /** + * Default body cap for agent responses on the MCP and A2A call paths, + * and for agent-card fetches: 4 MiB. + * + *

    Agent responses are untrusted input. This cap is on by default so a + * single peer cannot memory-DoS the caller with an oversized payload. + * Raise it per client, per agent or per call — see + * {@code specs/response-size-limits.md}. + */ + public static final long DEFAULT_MAX_CALL_RESPONSE_BYTES = 4L * 1024 * 1024; + + /** Upper bound accepted by {@link Builder#maxResponseBytes} (64 MiB). */ + public static final long MAX_PROBE_RESPONSE_BYTES = 64L * 1024 * 1024; + private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(30); private static final String DEFAULT_USER_AGENT = "adcp-java-sdk/0.1"; private final SsrfPolicy ssrfPolicy; private final long maxResponseBytes; + private final long maxCallResponseBytes; private final Duration connectTimeout; private final Duration readTimeout; private final String userAgent; @@ -60,6 +103,7 @@ public final class AdcpHttpClient implements AutoCloseable { private AdcpHttpClient(Builder builder) { this.ssrfPolicy = builder.ssrfPolicy; this.maxResponseBytes = builder.maxResponseBytes; + this.maxCallResponseBytes = builder.maxCallResponseBytes; this.connectTimeout = builder.connectTimeout; this.readTimeout = builder.readTimeout; this.userAgent = builder.userAgent; @@ -81,8 +125,13 @@ public static Builder builder() { *

    The hostname is resolved via DNS and all addresses are validated * against the {@link SsrfPolicy}. The original URI is preserved so TLS * SNI and hostname verification continue to use the hostname instead of - * an IP literal. Redirects are never followed automatically. The response - * body is capped at {@link #maxResponseBytes()}. + * an IP literal. Redirects are never followed automatically. + * + *

    This is the probe path: the body is capped at the probe cap + * {@link #maxResponseBytes()} (4 KiB by default) and a longer body stops + * being read, with {@link AdcpHttpResponse#truncated()} set. Callers that + * parse the body must reject truncated responses — or use + * {@link #getForAgentCard}, which enforces the strict call cap instead. * * @param method HTTP method (GET, POST, etc.) * @param uri target URI @@ -140,6 +189,10 @@ public AdcpHttpResponse send( requestBuilder.method(method, HttpRequest.BodyPublishers.noBody()); } + // Always ask for an uncompressed body so the cap counts the bytes we + // actually buffer. The JDK HttpClient never decompresses transparently. + requestBuilder.setHeader("Accept-Encoding", "identity"); + // Step 3: Send with body-capped response handler HttpResponse response = httpClient.send( requestBuilder.build(), @@ -148,7 +201,7 @@ public AdcpHttpResponse send( // Step 4: Read body with cap enforcement. // Ensure the InputStream is closed even if readBodyWithCap throws. try { - return readBodyWithCap(response); + return readBodyWithCap(response, maxResponseBytes, false, validatedUri); } catch (Throwable t) { try { response.body().close(); @@ -177,9 +230,16 @@ public AdcpHttpResponse get(URI uri, Map headers) * forwarding risky (transparent redirect-follow, DNS rebinding) are neutralised * by the client's {@code followRedirects(NEVER)} policy and SSRF validation. * + *

    The body is parsed by the caller, so it is held to the strict call cap + * ({@link #maxCallResponseBytes()}): an over-cap card throws + * {@link org.adcontextprotocol.adcp.error.ResponseTooLargeError} rather than + * arriving truncated. + * * @param uri target URI (SSRF-validated) * @param headers headers to forward, including any auth material - * @return the response, body capped at {@link #maxResponseBytes()} + * @return the response, body capped at {@link #maxCallResponseBytes()} + * @throws org.adcontextprotocol.adcp.error.ResponseTooLargeError if the card + * exceeds {@link #maxCallResponseBytes()} */ public AdcpHttpResponse getForAgentCard(URI uri, Map headers) throws IOException, InterruptedException { @@ -212,6 +272,7 @@ public AdcpHttpResponse getForAgentCard(URI uri, Map headers) }); } + requestBuilder.setHeader("Accept-Encoding", "identity"); requestBuilder.method("GET", HttpRequest.BodyPublishers.noBody()); HttpResponse response = httpClient.send( @@ -219,7 +280,7 @@ public AdcpHttpResponse getForAgentCard(URI uri, Map headers) HttpResponse.BodyHandlers.ofInputStream()); try { - return readBodyWithCap(response); + return readBodyWithCap(response, maxCallResponseBytes, true, validatedUri); } catch (Throwable t) { try { response.body().close(); @@ -243,27 +304,68 @@ public SsrfPolicy ssrfPolicy() { return ssrfPolicy; } - /** Maximum response body size in bytes. */ + /** Maximum probe response body size in bytes (see {@link #send}). */ public long maxResponseBytes() { return maxResponseBytes; } /** - * Creates an MCP transport client builder with the same connection-timeout - * and redirect policy used by this client. + * Maximum agent-response body size in bytes for paths whose body is parsed: + * agent-card fetches and the MCP/A2A call transports. Default 4 MiB. + */ + public long maxCallResponseBytes() { + return maxCallResponseBytes; + } + + /** + * Creates an MCP transport client builder with the same connection-timeout, + * redirect policy and response cap used by this client. */ public HttpClient.Builder newMcpClientBuilder() { return newHttpClientBuilder(); } /** - * Creates an HTTP client builder with this client's connection-timeout and - * redirect policy ({@code NEVER}). Suitable for any transport (MCP, A2A, etc.). + * Creates an MCP transport client builder with an explicit response cap. + * + * @param maxResponseBytes per-connection response cap in bytes + */ + public HttpClient.Builder newMcpClientBuilder(long maxResponseBytes) { + return newHttpClientBuilder(maxResponseBytes); + } + + /** + * Creates an HTTP client builder with this client's connection-timeout, + * redirect policy ({@code NEVER}) and call-response cap + * ({@link #maxCallResponseBytes()}). Suitable for any transport + * (MCP, A2A, etc.). */ public HttpClient.Builder newHttpClientBuilder() { - return HttpClient.newBuilder() - .connectTimeout(connectTimeout) - .followRedirects(HttpClient.Redirect.NEVER); + return newHttpClientBuilder(maxCallResponseBytes); + } + + /** + * Creates an HTTP client builder with an explicit response cap, for + * transports that need a per-agent or per-call override. + * + *

    The returned builder produces a + * {@link ResponseSizeLimitingHttpClient}: every response it reads is + * counted as it streams and aborted with + * {@link org.adcontextprotocol.adcp.error.ResponseTooLargeError} once the + * cap is crossed. + * + * @param maxResponseBytes response cap in bytes; must be positive + */ + public HttpClient.Builder newHttpClientBuilder(long maxResponseBytes) { + if (maxResponseBytes <= 0) { + throw new IllegalArgumentException( + "maxResponseBytes must be positive: " + maxResponseBytes); + } + return new ResponseSizeLimitingHttpClient.LimitingClientBuilder( + HttpClient.newBuilder() + .connectTimeout(connectTimeout) + .followRedirects(HttpClient.Redirect.NEVER), + maxResponseBytes); } @Override @@ -333,9 +435,25 @@ private static boolean isLoopback(String host) { || "::1".equals(host); } - private AdcpHttpResponse readBodyWithCap(HttpResponse response) + /** + * Reads the body, counting bytes as they arrive and stopping at {@code cap}. + * + *

    A declared {@code Content-Length} over the cap is rejected before any + * body byte is read. In {@code strict} mode an over-cap body throws + * {@link ResponseTooLargeError}; otherwise (probe paths, where the body is + * not parsed) reading stops at the cap and the result is flagged via + * {@link AdcpHttpResponse#truncated()}. + */ + private AdcpHttpResponse readBodyWithCap(HttpResponse response, + long cap, boolean strict, URI uri) throws IOException { - long cap = maxResponseBytes; + if (strict) { + long declared = declaredContentLength(response); + if (declared > cap) { + throw new ResponseTooLargeError(cap, declared, uri); + } + } + boolean truncated = false; long totalRead = 0; @@ -348,18 +466,20 @@ private AdcpHttpResponse readBodyWithCap(HttpResponse response) totalRead += n; if (totalRead <= cap) { baos.write(buf, 0, n); - } else if (!truncated) { - // Write only the portion up to the cap - int remaining = (int) (cap - (totalRead - n)); - if (remaining > 0) { - baos.write(buf, 0, remaining); - } - truncated = true; - log.debug("Response body truncated at {} bytes (cap={})", - totalRead, cap); - // Stop reading — don't consume the rest - break; + continue; + } + if (strict) { + throw new ResponseTooLargeError(cap, totalRead, uri); } + // Probe path: keep the bytes up to the cap, flag, stop reading. + int remaining = (int) (cap - (totalRead - n)); + if (remaining > 0) { + baos.write(buf, 0, remaining); + } + truncated = true; + log.debug("Probe response body truncated at {} bytes (cap={})", + totalRead, cap); + break; } return new AdcpHttpResponse( @@ -371,6 +491,14 @@ private AdcpHttpResponse readBodyWithCap(HttpResponse response) } } + private static long declaredContentLength(HttpResponse response) { + try { + return response.headers().firstValueAsLong("content-length").orElse(-1L); + } catch (NumberFormatException e) { + return -1L; + } + } + private static boolean isProtectedHeader(String name) { return ProtectedHeaders.isProtected(name); } @@ -380,6 +508,7 @@ private static boolean isProtectedHeader(String name) { public static final class Builder { private SsrfPolicy ssrfPolicy = SsrfPolicy.strict(); private long maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES; + private long maxCallResponseBytes = DEFAULT_MAX_CALL_RESPONSE_BYTES; private Duration connectTimeout = DEFAULT_CONNECT_TIMEOUT; private Duration readTimeout = DEFAULT_READ_TIMEOUT; private String userAgent = DEFAULT_USER_AGENT; @@ -398,20 +527,45 @@ public Builder ssrfPolicy(SsrfPolicy ssrfPolicy) { } /** - * Maximum response body size in bytes. Responses exceeding this - * are truncated and flagged via {@link AdcpHttpResponse#truncated()}. + * Maximum probe response body size in bytes. Probe responses + * exceeding this stop being read and are flagged via + * {@link AdcpHttpResponse#truncated()}. * Default: {@value #DEFAULT_MAX_RESPONSE_BYTES} (4 KiB). - * Maximum: 64 MB. + * Maximum: 64 MiB. + * + *

    This is not the cap on agent responses — that is + * {@link #maxCallResponseBytes(long)}. */ public Builder maxResponseBytes(long maxResponseBytes) { - if (maxResponseBytes <= 0 || maxResponseBytes > 64 * 1024 * 1024) { + if (maxResponseBytes <= 0 || maxResponseBytes > MAX_PROBE_RESPONSE_BYTES) { throw new IllegalArgumentException( - "maxResponseBytes must be in (0, 67108864]: " + maxResponseBytes); + "maxResponseBytes must be in (0, " + MAX_PROBE_RESPONSE_BYTES + + "]: " + maxResponseBytes); } this.maxResponseBytes = maxResponseBytes; return this; } + /** + * Maximum agent-response body size in bytes for paths whose body is + * parsed: agent-card fetches and the MCP/A2A call transports. + * Over-cap responses throw + * {@link org.adcontextprotocol.adcp.error.ResponseTooLargeError}. + * + *

    Default: 4 MiB ({@link #DEFAULT_MAX_CALL_RESPONSE_BYTES}). Raise it + * only for agents that legitimately publish very large catalogues — + * agent responses are untrusted input and the cap is what keeps one + * peer from exhausting the caller's memory. + */ + public Builder maxCallResponseBytes(long maxCallResponseBytes) { + if (maxCallResponseBytes <= 0) { + throw new IllegalArgumentException( + "maxCallResponseBytes must be positive: " + maxCallResponseBytes); + } + this.maxCallResponseBytes = maxCallResponseBytes; + return this; + } + /** Connection timeout. Default: 10 seconds. */ public Builder connectTimeout(Duration connectTimeout) { this.connectTimeout = Objects.requireNonNull(connectTimeout); diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java new file mode 100644 index 0000000..72b7d78 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java @@ -0,0 +1,582 @@ +package org.adcontextprotocol.adcp.http; + +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.InetAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; + +/** + * A {@link HttpClient} decorator that enforces a response-size cap + * while the body streams in. + * + *

    Agent responses are untrusted input. Without a cap, a single peer can + * exhaust the caller's memory by answering a tool call with a multi-gigabyte + * body. This decorator exists because the MCP + * ({@code HttpClientStreamableHttpTransport} / {@code HttpClientSseClientTransport}) + * and A2A ({@code JdkA2AHttpClient}) transports accept a JDK {@link HttpClient} + * but expose no body-size hook of their own — wrapping the client is the only + * choke point that covers every call they make. + * + *

    Enforcement: + *

      + *
    1. Pre-reject on {@code Content-Length}. A declared length above + * the cap fails before a single body byte is read.
    2. + *
    3. Count while reading. Bytes are counted in + * {@link Flow.Subscriber#onNext}; the subscription is cancelled the + * moment the cap is crossed, so the oversized body is never buffered.
    4. + *
    5. SSE per event and per call. For + * {@code Content-Type: text/event-stream}, bytes are counted per event + * (reset at each blank-line delimiter) so one huge event cannot escape + * the cap, and cumulatively for the whole response when the + * response answers a single call (any non-{@code GET} request). A + * long-lived {@code GET} listening stream — the MCP SSE session channel, + * which carries every server-to-client message for the life of the + * connection — gets the per-event cap only, because a cumulative cap + * there would kill healthy long sessions. Per-event is the cap that + * matters for memory: the transports aggregate one event at a time and + * discard the bytes in between.
    6. + *
    7. Decompressed bytes. Outbound requests are rewritten to + * {@code Accept-Encoding: identity} so the counted wire bytes are the + * bytes the caller will hold. The JDK {@link HttpClient} never + * transparently decompresses, so a peer cannot hide a 1 GiB payload + * behind a 1 MiB gzip stream.
    8. + *
    + * + *

    Oversized responses fail the response future with + * {@link ResponseTooLargeError} — never truncated, never silently dropped. + * Because the JDK may wrap a body-subscriber failure before it reaches the + * caller, use {@link ResponseTooLargeError#findIn(Throwable)} to recover the + * typed error from a cause chain. + * + * @see AdcpHttpClient#newHttpClientBuilder(long) + * @see ResponseTooLargeError + */ +public final class ResponseSizeLimitingHttpClient extends HttpClient { + + private static final Logger log = + LoggerFactory.getLogger(ResponseSizeLimitingHttpClient.class); + + private static final String SSE_CONTENT_TYPE = "text/event-stream"; + private static final String ACCEPT_ENCODING = "Accept-Encoding"; + private static final String IDENTITY = "identity"; + + private final HttpClient delegate; + private final long maxResponseBytes; + + private ResponseSizeLimitingHttpClient(HttpClient delegate, long maxResponseBytes) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + if (maxResponseBytes <= 0) { + throw new IllegalArgumentException( + "maxResponseBytes must be positive: " + maxResponseBytes); + } + this.maxResponseBytes = maxResponseBytes; + } + + /** + * Wraps {@code delegate} so that every response it produces is capped at + * {@code maxResponseBytes}. + * + * @param delegate the client to decorate + * @param maxResponseBytes cap in bytes; must be positive + * @return the decorated client + */ + public static ResponseSizeLimitingHttpClient wrap(HttpClient delegate, + long maxResponseBytes) { + return new ResponseSizeLimitingHttpClient(delegate, maxResponseBytes); + } + + /** The cap in bytes applied to every response from this client. */ + public long maxResponseBytes() { + return maxResponseBytes; + } + + // -- HttpClient: the three methods that actually carry a body -- + + @Override + public HttpResponse send(HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler) + throws IOException, InterruptedException { + return delegate.send(identityEncoding(request), + limiting(request, responseBodyHandler)); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + return delegate.sendAsync(identityEncoding(request), + limiting(request, responseBodyHandler)); + } + + @Override + public CompletableFuture> sendAsync( + HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.@Nullable PushPromiseHandler pushPromiseHandler) { + return delegate.sendAsync(identityEncoding(request), + limiting(request, responseBodyHandler), + limiting(pushPromiseHandler)); + } + + // -- HttpClient: pure delegation -- + + @Override + public Optional cookieHandler() { + return delegate.cookieHandler(); + } + + @Override + public Optional connectTimeout() { + return delegate.connectTimeout(); + } + + @Override + public Redirect followRedirects() { + return delegate.followRedirects(); + } + + @Override + public Optional proxy() { + return delegate.proxy(); + } + + @Override + public SSLContext sslContext() { + return delegate.sslContext(); + } + + @Override + public SSLParameters sslParameters() { + return delegate.sslParameters(); + } + + @Override + public Optional authenticator() { + return delegate.authenticator(); + } + + @Override + public Version version() { + return delegate.version(); + } + + @Override + public Optional executor() { + return delegate.executor(); + } + + @Override + public WebSocket.Builder newWebSocketBuilder() { + return delegate.newWebSocketBuilder(); + } + + @Override + public void shutdown() { + delegate.shutdown(); + } + + @Override + public boolean awaitTermination(Duration duration) throws InterruptedException { + return delegate.awaitTermination(duration); + } + + @Override + public boolean isTerminated() { + return delegate.isTerminated(); + } + + @Override + public void shutdownNow() { + delegate.shutdownNow(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public String toString() { + return "ResponseSizeLimitingHttpClient[maxResponseBytes=" + maxResponseBytes + + ", delegate=" + delegate + "]"; + } + + // -- internal -- + + /** + * Rewrites the request to ask for an uncompressed body, so the bytes this + * client counts are the bytes the caller ends up holding. + */ + private static HttpRequest identityEncoding(HttpRequest request) { + String current = request.headers().firstValue(ACCEPT_ENCODING).orElse(null); + if (IDENTITY.equalsIgnoreCase(current)) { + return request; + } + if (current != null) { + log.debug("Replacing Accept-Encoding '{}' with 'identity' so the response" + + " size cap counts uncompressed bytes", current); + } + return HttpRequest.newBuilder(request, + (name, value) -> !ACCEPT_ENCODING.equalsIgnoreCase(name)) + .header(ACCEPT_ENCODING, IDENTITY) + .build(); + } + + private HttpResponse.BodyHandler limiting( + HttpRequest request, HttpResponse.BodyHandler delegateHandler) { + Objects.requireNonNull(delegateHandler, "responseBodyHandler"); + // A GET that comes back as SSE is a long-lived listening stream, not the + // response to a single call: cap each event but not the running total. + boolean listeningStream = "GET".equalsIgnoreCase(request.method()); + URI uri = request.uri(); + long cap = maxResponseBytes; + return responseInfo -> { + long declared = declaredContentLength(responseInfo); + if (declared > cap) { + log.debug("Rejecting response from {}: Content-Length {} exceeds cap {}", + uri, declared, cap); + return new FailedBodySubscriber<>( + new ResponseTooLargeError(cap, declared, uri)); + } + boolean sse = isEventStream(responseInfo); + return new LimitingBodySubscriber<>( + delegateHandler.apply(responseInfo), + cap, sse, !(sse && listeningStream), uri); + }; + } + + private HttpResponse.@Nullable PushPromiseHandler limiting( + HttpResponse.@Nullable PushPromiseHandler delegateHandler) { + if (delegateHandler == null) { + return null; + } + return (initiatingRequest, pushPromiseRequest, acceptor) -> + delegateHandler.applyPushPromise(initiatingRequest, pushPromiseRequest, + handler -> acceptor.apply(limiting(pushPromiseRequest, handler))); + } + + private static long declaredContentLength(HttpResponse.ResponseInfo responseInfo) { + try { + return responseInfo.headers().firstValueAsLong("content-length").orElse(-1L); + } catch (NumberFormatException e) { + // Malformed Content-Length — treat as unknown and let the streaming + // counter do the work. + return -1L; + } + } + + private static boolean isEventStream(HttpResponse.ResponseInfo responseInfo) { + return responseInfo.headers().firstValue("content-type") + .map(ct -> ct.toLowerCase(Locale.ROOT).contains(SSE_CONTENT_TYPE)) + .orElse(false); + } + + /** + * Fails the response immediately without reading any body — used when + * {@code Content-Length} already declares an oversized payload. + */ + private static final class FailedBodySubscriber + implements HttpResponse.BodySubscriber { + + private final Throwable error; + + FailedBodySubscriber(Throwable error) { + this.error = error; + } + + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.cancel(); + } + + @Override + public void onNext(List item) { + // never requested + } + + @Override + public void onError(Throwable throwable) { + // already failed + } + + @Override + public void onComplete() { + // already failed + } + + @Override + public CompletionStage getBody() { + return CompletableFuture.failedFuture(error); + } + } + + /** + * Counts bytes as the body streams in and aborts the subscription the + * moment the cap is crossed. + * + *

    The reactive-streams contract guarantees {@code onNext} is signalled + * serially, so the counters need no locking — but successive signals may + * arrive on different carrier threads, so they are {@code volatile} for + * visibility. The per-byte scan works on locals and writes back once per + * {@code onNext} to keep that cheap. The terminal-state flag is atomic + * because {@code onError} may race with a cap breach. + */ + private static final class LimitingBodySubscriber + implements HttpResponse.BodySubscriber { + + private final HttpResponse.BodySubscriber delegate; + private final long maxBytes; + private final boolean sse; + private final boolean enforceTotal; + private final @Nullable URI uri; + private final CompletableFuture body = new CompletableFuture<>(); + private final AtomicBoolean aborted = new AtomicBoolean(); + + private volatile Flow.@Nullable Subscription subscription; + + private volatile long totalBytes; + private volatile long eventBytes; + private volatile int terminators; + private volatile boolean pendingCr; + + LimitingBodySubscriber(HttpResponse.BodySubscriber delegate, long maxBytes, + boolean sse, boolean enforceTotal, @Nullable URI uri) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.maxBytes = maxBytes; + this.sse = sse; + this.enforceTotal = enforceTotal; + this.uri = uri; + delegate.getBody().whenComplete((value, failure) -> { + if (failure != null) { + body.completeExceptionally(failure); + } else { + body.complete(value); + } + }); + } + + @Override + public void onSubscribe(Flow.Subscription subscription) { + this.subscription = subscription; + delegate.onSubscribe(subscription); + } + + @Override + public void onNext(List buffers) { + if (aborted.get()) { + return; + } + for (ByteBuffer buffer : buffers) { + totalBytes += buffer.remaining(); + if (enforceTotal && totalBytes > maxBytes) { + abort(totalBytes); + return; + } + if (sse && scanForOversizedEvent(buffer)) { + abort(eventBytes); + return; + } + } + delegate.onNext(buffers); + } + + @Override + public void onError(Throwable throwable) { + if (aborted.get()) { + return; + } + delegate.onError(throwable); + } + + @Override + public void onComplete() { + if (aborted.get()) { + return; + } + delegate.onComplete(); + } + + @Override + public CompletionStage getBody() { + return body; + } + + /** + * Counts bytes since the last SSE event delimiter without consuming the + * buffer (absolute gets only — the delegate still sees the full body). + * + * @return {@code true} if the current event has crossed the cap + */ + private boolean scanForOversizedEvent(ByteBuffer buffer) { + long bytes = eventBytes; + int runOfTerminators = terminators; + boolean cr = pendingCr; + boolean oversized = false; + + for (int i = buffer.position(); i < buffer.limit(); i++) { + byte b = buffer.get(i); + bytes++; + if (cr) { + cr = false; + runOfTerminators++; // the CR ended a line + if (b == '\r') { + cr = true; + } else if (b != '\n') { // CRLF counts as one terminator + runOfTerminators = 0; // content: the blank-line run is broken + } + } else if (b == '\r') { + cr = true; + } else if (b == '\n') { + runOfTerminators++; + } else { + runOfTerminators = 0; + } + if (runOfTerminators >= 2) { // blank line: end of event + runOfTerminators = 0; + bytes = 0; + } else if (bytes > maxBytes) { + oversized = true; + break; + } + } + + eventBytes = bytes; + terminators = runOfTerminators; + pendingCr = cr; + return oversized; + } + + private void abort(long bytesRead) { + if (!aborted.compareAndSet(false, true)) { + return; + } + ResponseTooLargeError error = new ResponseTooLargeError(maxBytes, bytesRead, uri); + log.debug("Aborting response from {}: {}", uri, error.getMessage()); + // Complete our own future first so the delegate's terminal signal + // cannot overwrite the typed error. + body.completeExceptionally(error); + Flow.Subscription current = subscription; + if (current != null) { + current.cancel(); + } + try { + delegate.onError(error); + } catch (RuntimeException e) { + log.debug("Delegate body subscriber rejected the abort signal: {}", + e.toString()); + } + } + } + + /** + * An {@link HttpClient.Builder} that produces size-limited clients. + * + *

    Lets a size cap be handed to third-party transports that only accept a + * {@code HttpClient.Builder} (the MCP transports do exactly this). + */ + static final class LimitingClientBuilder implements HttpClient.Builder { + + private final HttpClient.Builder delegate; + private final long maxResponseBytes; + + LimitingClientBuilder(HttpClient.Builder delegate, long maxResponseBytes) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.maxResponseBytes = maxResponseBytes; + } + + @Override + public HttpClient.Builder cookieHandler(CookieHandler cookieHandler) { + delegate.cookieHandler(cookieHandler); + return this; + } + + @Override + public HttpClient.Builder connectTimeout(Duration duration) { + delegate.connectTimeout(duration); + return this; + } + + @Override + public HttpClient.Builder sslContext(SSLContext sslContext) { + delegate.sslContext(sslContext); + return this; + } + + @Override + public HttpClient.Builder sslParameters(SSLParameters sslParameters) { + delegate.sslParameters(sslParameters); + return this; + } + + @Override + public HttpClient.Builder executor(Executor executor) { + delegate.executor(executor); + return this; + } + + @Override + public HttpClient.Builder followRedirects(Redirect policy) { + delegate.followRedirects(policy); + return this; + } + + @Override + public HttpClient.Builder version(Version version) { + delegate.version(version); + return this; + } + + @Override + public HttpClient.Builder priority(int priority) { + delegate.priority(priority); + return this; + } + + @Override + public HttpClient.Builder proxy(ProxySelector proxySelector) { + delegate.proxy(proxySelector); + return this; + } + + @Override + public HttpClient.Builder authenticator(Authenticator authenticator) { + delegate.authenticator(authenticator); + return this; + } + + @Override + public HttpClient.Builder localAddress(InetAddress localAddr) { + delegate.localAddress(localAddr); + return this; + } + + @Override + public HttpClient build() { + return wrap(delegate.build(), maxResponseBytes); + } + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/CallToolOptions.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/CallToolOptions.java index 0c6de00..8800df7 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/CallToolOptions.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/CallToolOptions.java @@ -7,15 +7,24 @@ /** * Options for a single {@code callTool()} invocation. * - *

    Implementation status: In v0.1, MCP transport applies a - * fixed 10 MB content limit regardless of {@code maxResponseBytes}. - * The {@code timeout} and {@code maxResponseBytes} fields are accepted - * for forward compatibility but are not yet wired into the - * MCP transport path. They will be enforced when the call-level timeout - * and per-agent body-cap features ship (planned v0.2). + *

    {@code maxResponseBytes} overrides the response size cap for this call on + * both the MCP and A2A transports. It is enforced while the body streams in; + * an over-cap response throws + * {@link org.adcontextprotocol.adcp.error.ResponseTooLargeError}. Resolution + * order is call → agent ({@code AgentConfig.maxResponseBytes}) → client + * ({@code AdcpClient.Builder.maxResponseBytes}, default 4 MiB). + * + *

    Overriding the cap selects a transport connection built for that cap, so + * varying it per call fragments the connection cache. Prefer setting it once on + * the {@link org.adcontextprotocol.adcp.AgentConfig} for agents that routinely + * answer with large catalogues. + * + *

    Implementation status: {@code timeout} is accepted for forward + * compatibility but is not yet wired into the transport path; the + * client-level {@code requestTimeout} applies instead. * * @param timeout per-call timeout (overrides client default) — reserved, not yet enforced - * @param maxResponseBytes per-call body cap (overrides client default) — reserved, not yet enforced + * @param maxResponseBytes per-call response cap in bytes (overrides agent and client defaults) * @param validateResponse whether to validate the response against schema */ public record CallToolOptions( @@ -24,7 +33,10 @@ public record CallToolOptions( boolean validateResponse ) { - /** Default options: no timeout override, no body cap override, validation off. */ + /** + * Default options: no timeout override, no cap override (the agent or + * client default applies), validation off. + */ public static final CallToolOptions DEFAULT = new CallToolOptions(null, null, false); public static Builder builder() { @@ -43,6 +55,11 @@ public Builder timeout(Duration timeout) { return this; } + /** + * Caps this call's response at {@code maxResponseBytes}, overriding the + * agent and client defaults. Must be positive — the cap cannot be + * disabled. + */ public Builder maxResponseBytes(long maxResponseBytes) { if (maxResponseBytes <= 0) { throw new IllegalArgumentException( diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java index 5a3bbe6..eb4bd1a 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java @@ -86,9 +86,12 @@ public T callTool(AgentConfig agent, String toolName, // 1. Validate agent URL against SSRF policy validateUrl(agent); - // 2. Warn if non-default options are passed (not yet enforced in v0.1) - if (!options.equals(CallToolOptions.DEFAULT)) { - log.debug("CallToolOptions fields are not yet enforced by the MCP transport (v0.1)"); + // 2. Resolve the response size cap: call override, then agent, then + // the client default baked into the connection managers. + Long maxResponseBytes = resolveMaxResponseBytes(agent, options); + if (options.timeout() != null) { + log.debug("CallToolOptions.timeout is not yet enforced by the transports; " + + "the client-level requestTimeout applies"); } // 3. Resolve auth headers @@ -113,8 +116,22 @@ public T callTool(AgentConfig agent, String toolName, // 6. Dispatch to transport return agent.protocol() == Protocol.A2A - ? callViaA2a(agent, toolName, mergedArgs, allHeaders, responseType) - : callViaMcp(agent, toolName, mergedArgs, allHeaders, responseType); + ? callViaA2a(agent, toolName, mergedArgs, allHeaders, responseType, + maxResponseBytes) + : callViaMcp(agent, toolName, mergedArgs, allHeaders, responseType, + maxResponseBytes); + } + + /** + * Resolves the response size cap for a call: explicit call option, then the + * agent's own setting, then {@code null} meaning "use the client default". + */ + private static @Nullable Long resolveMaxResponseBytes(AgentConfig agent, + CallToolOptions options) { + if (options.maxResponseBytes() != null) { + return options.maxResponseBytes(); + } + return agent.maxResponseBytes(); } /** @@ -137,13 +154,17 @@ public void close() { private T callViaMcp(AgentConfig agent, String toolName, Map mergedArgs, Map headers, - Class responseType) { + Class responseType, + @Nullable Long maxResponseBytes) { String cacheHash = computeCacheHash(agent); McpSyncClient client = mcpConnectionManager.getOrConnect( - agent.agentUri(), headers, cacheHash); + agent.agentUri(), headers, cacheHash, maxResponseBytes); + long cap = maxResponseBytes != null + ? maxResponseBytes + : mcpConnectionManager.defaultMaxResponseBytes(); try { - return mcpCaller.callTool(client, toolName, mergedArgs, responseType); + return mcpCaller.callTool(client, toolName, mergedArgs, responseType, cap); } catch (ProtocolError e) { if (!isTransportError(e)) { throw e; @@ -155,9 +176,9 @@ private T callViaMcp(AgentConfig agent, String toolName, ProtocolError original = e; client = mcpConnectionManager.getOrConnect( - agent.agentUri(), headers, cacheHash); + agent.agentUri(), headers, cacheHash, maxResponseBytes); try { - return mcpCaller.callTool(client, toolName, mergedArgs, responseType); + return mcpCaller.callTool(client, toolName, mergedArgs, responseType, cap); } catch (ProtocolError retry) { retry.addSuppressed(original); throw retry; @@ -166,6 +187,11 @@ private T callViaMcp(AgentConfig agent, String toolName, } private boolean isTransportError(ProtocolError e) { + // An oversized response surfaces as an IOException from the JDK HTTP + // stack, but retrying it just re-downloads the same oversized payload. + if (org.adcontextprotocol.adcp.error.ResponseTooLargeError.findIn(e) != null) { + return false; + } // Walk the full cause chain — any I/O or timeout failure is transient for (Throwable t = e.getCause(); t != null; t = t.getCause()) { if (t instanceof java.io.IOException @@ -179,11 +205,16 @@ private boolean isTransportError(ProtocolError e) { private T callViaA2a(AgentConfig agent, String toolName, Map mergedArgs, Map headers, - Class responseType) { + Class responseType, + @Nullable Long maxResponseBytes) { String cacheHash = computeCacheHash(agent); - var client = a2aConnectionManager.getOrConnect(agent, headers, cacheHash); + var client = a2aConnectionManager.getOrConnect( + agent, headers, cacheHash, maxResponseBytes); + long cap = maxResponseBytes != null + ? maxResponseBytes + : a2aConnectionManager.defaultMaxResponseBytes(); try { - return a2aCaller.callTool(client, toolName, mergedArgs, responseType, headers); + return a2aCaller.callTool(client, toolName, mergedArgs, responseType, headers, cap); } catch (ProtocolError e) { if (!isTransportError(e)) { throw e; @@ -193,9 +224,11 @@ private T callViaA2a(AgentConfig agent, String toolName, toolName, e.getMessage()); ProtocolError original = e; - client = a2aConnectionManager.getOrConnect(agent, headers, cacheHash); + client = a2aConnectionManager.getOrConnect( + agent, headers, cacheHash, maxResponseBytes); try { - return a2aCaller.callTool(client, toolName, mergedArgs, responseType, headers); + return a2aCaller.callTool(client, toolName, mergedArgs, responseType, + headers, cap); } catch (ProtocolError retry) { retry.addSuppressed(original); throw retry; diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aCaller.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aCaller.java index b8d73eb..fb6fc92 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aCaller.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aCaller.java @@ -17,6 +17,7 @@ import org.a2aproject.sdk.spec.TaskState; import org.a2aproject.sdk.spec.TextPart; import org.adcontextprotocol.adcp.error.ProtocolError; +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,11 +34,17 @@ /** * Calls AdCP tools over A2A JSON-RPC + SSE. + * + *

    The authoritative response size cap lives in the transport's HTTP client + * (see {@link org.adcontextprotocol.adcp.http.ResponseSizeLimitingHttpClient}), + * which aborts an oversized body while it streams. The part checks here are a + * backstop, and they translate a transport-level + * {@link ResponseTooLargeError} back into the typed error instead of burying it + * in a {@link ProtocolError}. */ public final class A2aCaller { private static final Logger log = LoggerFactory.getLogger(A2aCaller.class); - private static final int MAX_CONTENT_LENGTH = 10 * 1024 * 1024; private static final int MAX_ERROR_LENGTH = 500; private static final long RESPONSE_TIMEOUT_SECONDS = 30; private static final String TOOL_NAME_KEY = "adcp_tool_name"; @@ -55,12 +62,27 @@ public A2aCaller(ObjectMapper objectMapper) { public T callTool(Client client, String toolName, Map args, Class responseType) { - return callTool(new ClientAdapter(client), toolName, args, responseType, Map.of()); + return callTool(client, toolName, args, responseType, Map.of(), + org.adcontextprotocol.adcp.http.AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); } public T callTool(Client client, String toolName, Map args, Class responseType, Map headers) { + return callTool(client, toolName, args, responseType, headers, + org.adcontextprotocol.adcp.http.AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + } + + /** + * Calls an AdCP tool over A2A, rejecting response parts larger than + * {@code maxResponseBytes}. + * + * @param maxResponseBytes the effective response size cap for this call + * @throws ResponseTooLargeError if the agent's response exceeds the cap + */ + public T callTool(Client client, String toolName, + Map args, Class responseType, + Map headers, long maxResponseBytes) { for (var entry : headers.entrySet()) { String k = entry.getKey(); String v = entry.getValue(); @@ -70,12 +92,20 @@ public T callTool(Client client, String toolName, "callTool headers must not contain CR/LF or null: " + k); } } - return callTool(new ClientAdapter(client), toolName, args, responseType, headers); + return callTool(new ClientAdapter(client), toolName, args, responseType, headers, + maxResponseBytes); } T callTool(A2aMessageClient client, String toolName, Map args, Class responseType, Map headers) { + return callTool(client, toolName, args, responseType, headers, + org.adcontextprotocol.adcp.http.AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + } + + T callTool(A2aMessageClient client, String toolName, + Map args, Class responseType, + Map headers, long maxResponseBytes) { // Validate toolName before use — reject rather than silently mutate the outbound request if (toolName == null || toolName.isBlank()) { throw new IllegalArgumentException("toolName must not be null or blank"); @@ -139,7 +169,8 @@ T callTool(A2aMessageClient client, String toolName, if (failure.get() != null) { throw wrapFailure(safeToolName, failure.get()); } - return extractResponse(latestMessage.get(), latestTask.get(), responseType); + return extractResponse(latestMessage.get(), latestTask.get(), responseType, + maxResponseBytes); } catch (ProtocolError e) { throw e; } catch (A2AClientException e) { @@ -163,9 +194,10 @@ private MessageSendParams buildRequest(String toolName, Map args .build(); } - private T extractResponse(Message message, Task task, Class responseType) { + private T extractResponse(Message message, Task task, Class responseType, + long maxResponseBytes) { if (message != null) { - return extractFromParts(message.parts(), responseType); + return extractFromParts(message.parts(), responseType, maxResponseBytes); } if (task != null) { if (task.status() != null && task.status().state() == TaskState.TASK_STATE_FAILED) { @@ -179,14 +211,16 @@ private T extractResponse(Message message, Task task, Class responseType) null); } if (task.status() != null && task.status().message() != null) { - return extractFromParts(task.status().message().parts(), responseType); + return extractFromParts(task.status().message().parts(), responseType, + maxResponseBytes); } if (task.history() != null && !task.history().isEmpty()) { int limit = Math.min(task.history().size(), MAX_HISTORY_SCAN); for (int i = task.history().size() - 1; i >= task.history().size() - limit; i--) { Message historyMessage = task.history().get(i); if (historyMessage != null && historyMessage.parts() != null && !historyMessage.parts().isEmpty()) { - return extractFromParts(historyMessage.parts(), responseType); + return extractFromParts(historyMessage.parts(), responseType, + maxResponseBytes); } } } @@ -194,7 +228,8 @@ private T extractResponse(Message message, Task task, Class responseType) throw new ProtocolError("a2a", "Empty response from A2A sendMessage", null); } - private T extractFromParts(List> parts, Class responseType) { + private T extractFromParts(List> parts, Class responseType, + long maxResponseBytes) { if (parts == null || parts.isEmpty()) { throw new ProtocolError("a2a", "A2A response message had no parts", null); } @@ -206,14 +241,11 @@ private T extractFromParts(List> parts, Class responseType) { if (part instanceof DataPart dataPart) { try { byte[] bytes = objectMapper.writeValueAsBytes(dataPart.data()); - if (bytes.length > MAX_CONTENT_LENGTH) { - throw new ProtocolError("a2a", - "A2A DataPart response exceeds size limit (" - + bytes.length + " > " + MAX_CONTENT_LENGTH + ")", - null); + if (bytes.length > maxResponseBytes) { + throw new ResponseTooLargeError(maxResponseBytes, bytes.length, null); } return objectMapper.readValue(bytes, responseType); - } catch (ProtocolError e) { + } catch (ProtocolError | ResponseTooLargeError e) { throw e; } catch (Exception e) { if (firstParseError == null) { @@ -227,12 +259,11 @@ private T extractFromParts(List> parts, Class responseType) { if (text == null) { continue; } - int textBytes = text.getBytes(StandardCharsets.UTF_8).length; - if (textBytes > MAX_CONTENT_LENGTH) { - throw new ProtocolError("a2a", - "A2A response content exceeds size limit (" - + textBytes + " > " + MAX_CONTENT_LENGTH + ")", - null); + // UTF-8 is never fewer bytes than chars, so a char count over + // the cap is already over the cap — reject without measuring. + if (text.length() > maxResponseBytes + || text.getBytes(StandardCharsets.UTF_8).length > maxResponseBytes) { + throw new ResponseTooLargeError(maxResponseBytes, text.length(), null); } try { return objectMapper.readValue(text, responseType); @@ -264,7 +295,14 @@ private boolean isTerminal(Task task) { && task.status().state().isFinal(); } - private ProtocolError wrapFailure(String toolName, Throwable throwable) { + private RuntimeException wrapFailure(String toolName, Throwable throwable) { + // The cap is enforced inside the JDK body subscriber, so the typed error + // arrives wrapped. Surface it rather than hiding it in a ProtocolError, + // which the retry path would treat as transient. + ResponseTooLargeError tooLarge = ResponseTooLargeError.findIn(throwable); + if (tooLarge != null) { + return tooLarge; + } String message = throwable.getMessage(); return new ProtocolError("a2a", "A2A sendMessage failed for " + toolName + ": " + sanitizeErrorText(message), diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java index 15606f8..146223c 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java @@ -14,7 +14,9 @@ import org.adcontextprotocol.adcp.error.ProtocolError; import org.adcontextprotocol.adcp.http.AdcpHttpClient; import org.adcontextprotocol.adcp.http.AdcpHttpResponse; +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; import org.adcontextprotocol.adcp.http.ProtectedHeaders; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,6 +30,7 @@ import java.util.Map; import java.util.Objects; import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; import java.util.concurrent.locks.ReentrantLock; @@ -37,7 +40,9 @@ * *

    Headers are included in the cache key because agent-card discovery is * header-sensitive. The separate cache hash isolates clients by credentials - * without storing raw secrets in the cache key. + * without storing raw secrets in the cache key. The response size cap is in the + * key too: it is baked into the transport's {@link java.net.http.HttpClient}, + * so a call that overrides the cap must not reuse a client built with another. */ public final class A2aConnectionManager implements AutoCloseable { @@ -52,15 +57,24 @@ public final class A2aConnectionManager implements AutoCloseable { private final Semaphore[] connectStripes; private final AgentCardLoader agentCardLoader; private final ClientFactory clientFactory; + private final long defaultMaxResponseBytes; private volatile boolean closed; public A2aConnectionManager(AdcpHttpClient adcpHttpClient, ObjectMapper objectMapper) { - this(new HttpAgentCardLoader(adcpHttpClient, objectMapper), new DefaultClientFactory(adcpHttpClient)); + this(new HttpAgentCardLoader(adcpHttpClient, objectMapper), + new DefaultClientFactory(adcpHttpClient), + adcpHttpClient.maxCallResponseBytes()); } A2aConnectionManager(AgentCardLoader agentCardLoader, ClientFactory clientFactory) { + this(agentCardLoader, clientFactory, AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + } + + A2aConnectionManager(AgentCardLoader agentCardLoader, ClientFactory clientFactory, + long defaultMaxResponseBytes) { this.agentCardLoader = Objects.requireNonNull(agentCardLoader, "agentCardLoader"); this.clientFactory = Objects.requireNonNull(clientFactory, "clientFactory"); + this.defaultMaxResponseBytes = defaultMaxResponseBytes; this.connectStripes = new Semaphore[STRIPE_COUNT]; for (int i = 0; i < STRIPE_COUNT; i++) { connectStripes[i] = new Semaphore(1); @@ -68,14 +82,26 @@ public A2aConnectionManager(AdcpHttpClient adcpHttpClient, ObjectMapper objectMa } public Client getOrConnect(AgentConfig agent, Map headers, String cacheHash) { + return getOrConnect(agent, headers, cacheHash, null); + } + + /** + * Gets or creates a cached A2A client with an explicit response size cap. + * + * @param maxResponseBytes per-client response cap, or {@code null} for the + * client default + */ + public Client getOrConnect(AgentConfig agent, Map headers, String cacheHash, + @Nullable Long maxResponseBytes) { if (closed) { throw new IllegalStateException("A2aConnectionManager is closed"); } Objects.requireNonNull(cacheHash, "cacheHash"); Objects.requireNonNull(headers, "headers"); + long cap = effectiveMaxResponseBytes(maxResponseBytes); Map sanitizedAll = sanitizeHeaders(headers); Map sanitizedForKey = filterProtected(sanitizedAll); - String cacheKey = buildCacheKey(agent.agentUri(), sanitizedForKey, cacheHash); + String cacheKey = buildCacheKey(agent.agentUri(), sanitizedForKey, cacheHash, cap); cacheLock.lock(); try { @@ -109,7 +135,7 @@ public Client getOrConnect(AgentConfig agent, Map headers, Strin cacheLock.unlock(); } - Client client = connect(agent, sanitizedAll); + Client client = connect(agent, sanitizedAll, cap); cacheLock.lock(); try { @@ -136,12 +162,28 @@ public void evict(URI agentUri) { public void evict(URI agentUri, String cacheHash) { Objects.requireNonNull(cacheHash, "cacheHash"); - String prefix = agentUri + "#" + cacheHash; + String prefix = agentUri + "#" + cacheHash + "#"; // Evicts all cache entries for the given agent URI and credential hash, - // regardless of which non-secret discovery headers they used. - // This is intentional: on a transport error, all variants for those - // credentials are assumed stale. - evictMatching(key -> key.equals(prefix) || key.startsWith(prefix + "?")); + // regardless of which non-secret discovery headers or response cap they + // used. This is intentional: on a transport error, all variants for + // those credentials are assumed stale. + evictMatching(key -> key.startsWith(prefix)); + } + + /** The response cap applied when a call does not override it. */ + public long defaultMaxResponseBytes() { + return defaultMaxResponseBytes; + } + + private long effectiveMaxResponseBytes(@Nullable Long override) { + if (override == null) { + return defaultMaxResponseBytes; + } + if (override <= 0) { + throw new IllegalArgumentException( + "maxResponseBytes must be positive: " + override); + } + return override; } @Override @@ -154,14 +196,22 @@ public void close() { } finally { cacheLock.unlock(); } + try { + clientFactory.close(); + } catch (RuntimeException e) { + log.debug("Error closing A2A client factory: {}", sanitizeLogText(e.getMessage())); + } } - private Client connect(AgentConfig agent, Map sanitizedHeaders) { + private Client connect(AgentConfig agent, Map sanitizedHeaders, long cap) { try { AgentCard card = agentCardLoader.load(agent, sanitizedHeaders); - return clientFactory.create(card); + return clientFactory.create(card, cap); } catch (ProtocolError e) { throw e; + } catch (ResponseTooLargeError e) { + // An oversized agent card is a hard failure, not a connectivity blip. + throw e; } catch (A2AClientException e) { throw new ProtocolError("a2a", "Failed to create A2A client for " + agent.agentUri(), e); } catch (Exception e) { @@ -176,9 +226,17 @@ private Client connect(AgentConfig agent, Map sanitizedHeaders) * crafted {@code =} or {@code &} characters. */ static String buildCacheKey(URI agentUri, Map sanitizedHeaders, String cacheHash) { + return buildCacheKey(agentUri, sanitizedHeaders, cacheHash, + AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + } + + static String buildCacheKey(URI agentUri, Map sanitizedHeaders, String cacheHash, + long maxResponseBytes) { StringBuilder sb = new StringBuilder(agentUri.toString()) .append('#') - .append(cacheHash); + .append(cacheHash) + .append('#') + .append(maxResponseBytes); if (sanitizedHeaders.isEmpty()) { return sb.toString(); } @@ -299,29 +357,47 @@ interface AgentCardLoader { } interface ClientFactory { - Client create(AgentCard agentCard) throws A2AClientException; + Client create(AgentCard agentCard, long maxResponseBytes) throws A2AClientException; + + /** Releases any HTTP clients the factory owns. */ + default void close() { + } } private static final class DefaultClientFactory implements ClientFactory { + /** - * SSRF-safe HTTP client used by the A2A JSON-RPC transport. - * Backed by the same {@link java.net.http.HttpClient} that was built with - * {@code followRedirects(NEVER)} and the configured connect timeout, so - * the transport cannot follow HTTP redirects to internal addresses. - * Combined with {@link HttpAgentCardLoader#normalize} pinning AgentCard - * URLs to the validated agent URI, this closes the SSRF bypass that would - * otherwise exist in the default {@code JdkA2AHttpClient} (which uses - * {@code Redirect.NORMAL}). + * Distinct response caps that get their own pooled HTTP client. Bounded + * so a caller that varies {@code maxResponseBytes} per call cannot grow + * an unbounded pool of JDK HTTP clients. */ - private final org.a2aproject.sdk.client.http.A2AHttpClient safeHttpClient; + private static final int MAX_POOLED_CAPS = 8; + + private final AdcpHttpClient adcpHttpClient; + + /** + * SSRF-safe HTTP clients used by the A2A JSON-RPC transport, one per + * distinct response cap. + * + *

    Each is backed by a {@link java.net.http.HttpClient} built with + * {@code followRedirects(NEVER)}, the configured connect timeout, and a + * streaming response-size cap, so the transport can neither follow HTTP + * redirects to internal addresses nor be made to buffer an oversized + * agent response. Combined with {@link HttpAgentCardLoader#normalize} + * pinning AgentCard URLs to the validated agent URI, this closes the + * SSRF bypass that would otherwise exist in the default + * {@code JdkA2AHttpClient} (which uses {@code Redirect.NORMAL}). + */ + private final ConcurrentHashMap pooledClients = + new ConcurrentHashMap<>(); DefaultClientFactory(AdcpHttpClient adcpHttpClient) { - this.safeHttpClient = new JdkA2AHttpClient( - adcpHttpClient.newHttpClientBuilder().build()); + this.adcpHttpClient = Objects.requireNonNull(adcpHttpClient, "adcpHttpClient"); } @Override - public Client create(AgentCard agentCard) throws A2AClientException { + public Client create(AgentCard agentCard, long maxResponseBytes) + throws A2AClientException { ClientConfig config = ClientConfig.builder() .setStreaming(true) .setUseClientPreference(true) @@ -329,9 +405,38 @@ public Client create(AgentCard agentCard) throws A2AClientException { return Client.builder(agentCard) .clientConfig(config) .withTransport(JSONRPCTransport.class, - new JSONRPCTransportConfigBuilder().httpClient(safeHttpClient)) + new JSONRPCTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient(httpClientFor(maxResponseBytes)))) .build(); } + + private java.net.http.HttpClient httpClientFor(long maxResponseBytes) { + java.net.http.HttpClient pooled = pooledClients.get(maxResponseBytes); + if (pooled != null) { + return pooled; + } + if (pooledClients.size() >= MAX_POOLED_CAPS) { + log.warn("More than {} distinct A2A response caps in use; " + + "building an unpooled HTTP client for cap {}", + MAX_POOLED_CAPS, maxResponseBytes); + return adcpHttpClient.newHttpClientBuilder(maxResponseBytes).build(); + } + return pooledClients.computeIfAbsent(maxResponseBytes, + cap -> adcpHttpClient.newHttpClientBuilder(cap).build()); + } + + @Override + public void close() { + for (java.net.http.HttpClient client : pooledClients.values()) { + try { + client.close(); + } catch (RuntimeException e) { + log.debug("Error closing pooled A2A HTTP client: {}", + sanitizeLogText(e.getMessage())); + } + } + pooledClients.clear(); + } } private static final class HttpAgentCardLoader implements AgentCardLoader { diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpCaller.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpCaller.java index 1c3f98f..ba8f628 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpCaller.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpCaller.java @@ -5,6 +5,7 @@ import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema; import org.adcontextprotocol.adcp.error.ProtocolError; +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,13 +17,18 @@ * *

    Wraps the MCP SDK's {@code callTool()} API, extracting structured * content and deserializing to the target response type. + * + *

    The authoritative response size cap lives in the transport's HTTP client + * (see {@link org.adcontextprotocol.adcp.http.ResponseSizeLimitingHttpClient}), + * which aborts an oversized body while it streams. The content checks here are + * a backstop for payloads that arrive by some other route, and they translate a + * transport-level {@link ResponseTooLargeError} back into the typed error + * instead of burying it in a {@link ProtocolError}. */ public final class McpCaller { private static final Logger log = LoggerFactory.getLogger(McpCaller.class); - /** Maximum allowed TextContent length (10 MB, matching ObjectMapper limits). */ - private static final int MAX_CONTENT_LENGTH = 10 * 1024 * 1024; private final ObjectMapper objectMapper; @@ -48,21 +54,43 @@ public McpCaller(ObjectMapper objectMapper) { */ public T callTool(McpSyncClient client, String toolName, Map args, Class responseType) { + return callTool(client, toolName, args, responseType, + org.adcontextprotocol.adcp.http.AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + } + + /** + * Calls an MCP tool and deserializes the response, rejecting content larger + * than {@code maxResponseBytes}. + * + * @param maxResponseBytes the effective response size cap for this call + * @throws ResponseTooLargeError if the agent's response exceeds the cap + */ + public T callTool(McpSyncClient client, String toolName, + Map args, Class responseType, + long maxResponseBytes) { try { McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(toolName, args); McpSchema.CallToolResult result = client.callTool(request); - return extractResponse(result, responseType); + return extractResponse(result, responseType, maxResponseBytes); } catch (ProtocolError e) { throw e; } catch (Exception e) { + // The cap is enforced inside the JDK body subscriber, so the typed + // error arrives wrapped. Surface it rather than hiding it in a + // ProtocolError, which the retry path would treat as transient. + ResponseTooLargeError tooLarge = ResponseTooLargeError.findIn(e); + if (tooLarge != null) { + throw tooLarge; + } throw new ProtocolError("mcp", "MCP callTool failed for " + toolName + ": " + e.getMessage(), e); } } @SuppressWarnings("unchecked") - private T extractResponse(McpSchema.CallToolResult result, Class responseType) { + private T extractResponse(McpSchema.CallToolResult result, Class responseType, + long maxResponseBytes) { // If the tool itself reported an error, surface it before trying // to deserialize the content as a success payload. if (Boolean.TRUE.equals(result.isError())) { @@ -101,11 +129,10 @@ private T extractResponse(McpSchema.CallToolResult result, Class response log.debug("Skipping TextContent with null text"); continue; } - if (text.length() > MAX_CONTENT_LENGTH) { - throw new ProtocolError("mcp", - "MCP response content exceeds size limit (" - + text.length() + " > " - + MAX_CONTENT_LENGTH + ")", null); + // UTF-8 is never fewer bytes than chars, so a char count over + // the cap is already over the cap — reject without measuring. + if (text.length() > maxResponseBytes) { + throw new ResponseTooLargeError(maxResponseBytes, text.length(), null); } try { return objectMapper.readValue(text, responseType); diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManager.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManager.java index 5e6e47b..c0acdc5 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManager.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManager.java @@ -10,6 +10,7 @@ import org.adcontextprotocol.adcp.auth.WwwAuthenticateParser; import org.adcontextprotocol.adcp.error.AuthenticationRequiredError; import org.adcontextprotocol.adcp.error.ProtocolError; +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; import org.adcontextprotocol.adcp.http.AdcpHttpClient; import org.adcontextprotocol.adcp.http.AdcpHttpResponse; import org.jspecify.annotations.Nullable; @@ -30,7 +31,10 @@ /** * Manages cached MCP client connections with LRU eviction. * - *

    Cache key: {@code agentUrl::tokenHash}. Max {@value #MAX_CACHE_SIZE} entries. + *

    Cache key: {@code agentUrl::tokenHash::maxResponseBytes}. Max + * {@value #MAX_CACHE_SIZE} entries. The response cap is part of the key because + * it is baked into the transport's {@link java.net.http.HttpClient}: a call that + * overrides the cap must not reuse a connection built with a different one. * Implements StreamableHTTP → SSE fallback per TS SDK behavior. * *

    Thread-safe: cache reads/writes use {@code cacheLock} (short-held, never @@ -104,10 +108,29 @@ public McpConnectionManager(Duration connectTimeout, Duration requestTimeout, */ public McpSyncClient getOrConnect(URI agentUri, Map headers, String tokenHash) { + return getOrConnect(agentUri, headers, tokenHash, null); + } + + /** + * Gets or creates a cached MCP client connection with an explicit response + * size cap. + * + * @param agentUri the agent's base URI + * @param headers auth + extra headers to inject into MCP requests + * @param tokenHash hash of the auth token (for cache keying) + * @param maxResponseBytes per-connection response cap, or {@code null} to + * use the client default + * ({@link AdcpHttpClient#maxCallResponseBytes()}) + * @return a connected {@link McpSyncClient} + */ + public McpSyncClient getOrConnect(URI agentUri, Map headers, + String tokenHash, + @Nullable Long maxResponseBytes) { if (closed) { throw new IllegalStateException("McpConnectionManager is closed"); } - String cacheKey = agentUri + "::" + tokenHash; + long cap = effectiveMaxResponseBytes(maxResponseBytes); + String cacheKey = cacheKey(agentUri, tokenHash, cap); // Fast path: check cache under short lock (no I/O) cacheLock.lock(); @@ -147,7 +170,7 @@ public McpSyncClient getOrConnect(URI agentUri, Map headers, } // Network I/O happens here — only blocks threads in the same stripe - McpSyncClient client = connectWithFallback(agentUri, headers, cacheKey); + McpSyncClient client = connectWithFallback(agentUri, headers, cacheKey, cap); cacheLock.lock(); try { @@ -167,22 +190,47 @@ public McpSyncClient getOrConnect(URI agentUri, Map headers, } /** - * Evicts a specific connection from the cache. + * Evicts every cached connection for the given agent URI and token hash, + * regardless of the response cap it was built with. */ public void evict(URI agentUri, String tokenHash) { - String cacheKey = agentUri + "::" + tokenHash; + String prefix = agentUri + "::" + tokenHash + "::"; cacheLock.lock(); try { - McpSyncClient evicted = cache.remove(cacheKey); - if (evicted != null) { - knownStreamableKeys.remove(cacheKey); - closeQuietly(evicted); + var it = cache.entrySet().iterator(); + while (it.hasNext()) { + var entry = it.next(); + if (entry.getKey().startsWith(prefix)) { + it.remove(); + knownStreamableKeys.remove(entry.getKey()); + closeQuietly(entry.getValue()); + } } } finally { cacheLock.unlock(); } } + /** The response cap applied when a call does not override it. */ + public long defaultMaxResponseBytes() { + return adcpHttpClient.maxCallResponseBytes(); + } + + private long effectiveMaxResponseBytes(@Nullable Long override) { + if (override == null) { + return adcpHttpClient.maxCallResponseBytes(); + } + if (override <= 0) { + throw new IllegalArgumentException( + "maxResponseBytes must be positive: " + override); + } + return override; + } + + private static String cacheKey(URI agentUri, String tokenHash, long maxResponseBytes) { + return agentUri + "::" + tokenHash + "::" + maxResponseBytes; + } + /** * Evicts all cached connections for the given agent URI, regardless of * token hash. Use this when auth credentials rotate so that stale @@ -241,17 +289,18 @@ private void evictOldest() { * legitimate 5xx errors and double-charged every cold connect. */ private McpSyncClient connectWithFallback(URI agentUri, Map headers, - String cacheKey) { + String cacheKey, long maxResponseBytes) { String url = agentUri.toString(); Map safe = sanitizeHeaders(headers); // Known-good StreamableHTTP endpoints skip the probe if (knownStreamableKeys.contains(cacheKey)) { try { - McpSyncClient client = buildAndInit(url, safe, true); + McpSyncClient client = buildAndInit(url, safe, true, maxResponseBytes); log.debug("Reconnected to {} via StreamableHTTP (cached)", agentUri); return client; } catch (Exception e) { + rethrowIfResponseTooLarge(e); if (isAuthError(e)) { throw probeAndBuildAuthError(agentUri, e); } @@ -266,7 +315,7 @@ private McpSyncClient connectWithFallback(URI agentUri, Map head boolean useStreamable = probeSupportsStreamableHttp(agentUri, safe); try { - McpSyncClient client = buildAndInit(url, safe, useStreamable); + McpSyncClient client = buildAndInit(url, safe, useStreamable, maxResponseBytes); if (useStreamable) { knownStreamableKeys.add(cacheKey); } @@ -274,6 +323,7 @@ private McpSyncClient connectWithFallback(URI agentUri, Map head useStreamable ? "StreamableHTTP" : "SSE"); return client; } catch (Exception e) { + rethrowIfResponseTooLarge(e); if (isAuthError(e)) { throw probeAndBuildAuthError(agentUri, e); } @@ -282,10 +332,11 @@ private McpSyncClient connectWithFallback(URI agentUri, Map head log.debug("StreamableHTTP init failed despite probe, trying SSE for {}", agentUri); try { - McpSyncClient client = buildAndInit(url, safe, false); + McpSyncClient client = buildAndInit(url, safe, false, maxResponseBytes); log.debug("Connected to {} via SSE (fallback)", agentUri); return client; } catch (Exception e2) { + rethrowIfResponseTooLarge(e2); if (isAuthError(e2)) { throw probeAndBuildAuthError(agentUri, e2); } @@ -337,20 +388,20 @@ private boolean probeSupportsStreamableHttp(URI agentUri, Map he } private McpSyncClient buildAndInit(String url, Map headers, - boolean useStreamable) { + boolean useStreamable, long maxResponseBytes) { var reqBuilder = java.net.http.HttpRequest.newBuilder().timeout(requestTimeout); McpClientTransport transport = useStreamable ? HttpClientStreamableHttpTransport.builder(url) .connectTimeout(connectTimeout) .requestBuilder(reqBuilder) - .clientBuilder(adcpHttpClient.newMcpClientBuilder()) + .clientBuilder(adcpHttpClient.newMcpClientBuilder(maxResponseBytes)) .httpRequestCustomizer((rb, method, uri, body, ctx) -> headers.forEach(rb::header)) .build() : HttpClientSseClientTransport.builder(url) .connectTimeout(connectTimeout) .requestBuilder(reqBuilder) - .clientBuilder(adcpHttpClient.newMcpClientBuilder()) + .clientBuilder(adcpHttpClient.newMcpClientBuilder(maxResponseBytes)) .httpRequestCustomizer((rb, method, uri, body, ctx) -> headers.forEach(rb::header)) .build(); @@ -425,6 +476,18 @@ private AuthenticationRequiredError probeAndBuildAuthError(URI agentUri, Excepti return WwwAuthenticateParser.parse(wwwAuth); } + /** + * An oversized response is a hard failure, not a transport hiccup: rethrow + * it before the SSE fallback or the auth-probe path can mask it as a + * connectivity problem and retry against the same hostile peer. + */ + private static void rethrowIfResponseTooLarge(Exception e) { + ResponseTooLargeError tooLarge = ResponseTooLargeError.findIn(e); + if (tooLarge != null) { + throw tooLarge; + } + } + private boolean isAuthError(Exception e) { for (Throwable t = e; t != null; t = t.getCause()) { String msg = t.getMessage(); diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpClientTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpClientTest.java index 2877cce..7b1e931 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpClientTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/AdcpClientTest.java @@ -124,4 +124,26 @@ void builder_rejects_cross_major_version() { .adcpVersion(new AdcpVersion(2, null)) .build()); } + + @Test + void builder_rejects_a_non_positive_maxResponseBytes() { + assertThrows(ConfigurationError.class, + () -> AdcpClient.builder() + .agent(AgentConfig.mcp("test", AGENT_URI)) + .maxResponseBytes(0)); + assertThrows(ConfigurationError.class, + () -> AdcpClient.builder() + .agent(AgentConfig.mcp("test", AGENT_URI)) + .maxResponseBytes(-1)); + } + + @Test + void builder_accepts_a_raised_maxResponseBytes() { + try (AdcpClient client = AdcpClient.builder() + .agent(AgentConfig.mcp("test", AGENT_URI)) + .maxResponseBytes(32L * 1024 * 1024) + .build()) { + assertNotNull(client); + } + } } diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/AgentConfigTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/AgentConfigTest.java index 10c6259..357d497 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/AgentConfigTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/AgentConfigTest.java @@ -222,4 +222,35 @@ void rejects_extraHeaders_with_crlf_in_value() { .extraHeaders(Map.of("X-Key", "bad\nvalue")) .build()); } + + @Test + void maxResponseBytes_defaults_to_null_meaning_client_default() { + AgentConfig config = AgentConfig.builder().id("a1").agentUri(AGENT_URI).build(); + assertNull(config.maxResponseBytes()); + } + + @Test + void maxResponseBytes_overrides_the_client_default() { + AgentConfig config = AgentConfig.builder() + .id("a1") + .agentUri(AGENT_URI) + .maxResponseBytes(8L * 1024 * 1024) + .build(); + assertEquals(8L * 1024 * 1024, config.maxResponseBytes()); + assertTrue(config.toString().contains("maxResponseBytes=8388608")); + } + + @Test + void rejects_a_non_positive_maxResponseBytes() { + // The cap cannot be disabled — agent responses are untrusted input. + assertThrows(org.adcontextprotocol.adcp.error.ConfigurationError.class, + () -> AgentConfig.builder().id("a1").agentUri(AGENT_URI) + .maxResponseBytes(0)); + assertThrows(org.adcontextprotocol.adcp.error.ConfigurationError.class, + () -> AgentConfig.builder().id("a1").agentUri(AGENT_URI) + .maxResponseBytes(-1)); + assertThrows(org.adcontextprotocol.adcp.error.ConfigurationError.class, + () -> new AgentConfig("a1", AGENT_URI, Protocol.MCP, null, null, null, null, + null, null, null, java.util.Map.of(), -5L)); + } } diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/http/AdcpHttpClientBodyCapTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/http/AdcpHttpClientBodyCapTest.java new file mode 100644 index 0000000..e124358 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/http/AdcpHttpClientBodyCapTest.java @@ -0,0 +1,207 @@ +package org.adcontextprotocol.adcp.http; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Body-cap behaviour of {@link AdcpHttpClient} against a live loopback server. + * + *

    Two caps, two behaviours: the probe paths ({@code send}/{@code get}/ + * {@code post}) stop reading at the small probe cap and flag the result, + * because they only ever look at the status line and headers. The agent-card + * path parses what it gets, so it is held to the strict call cap and throws + * instead of handing back a half-read card. + */ +class AdcpHttpClientBodyCapTest { + + private HttpServer server; + private ExecutorService serverExecutor; + private String base; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + serverExecutor = Executors.newCachedThreadPool(); + server.setExecutor(serverExecutor); + + server.createContext("/chunked", exchange -> { + int bytes = intParam(exchange, "bytes"); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + writeFiller(exchange, bytes); + }); + + server.createContext("/declared", exchange -> { + int bytes = intParam(exchange, "bytes"); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes); + writeFiller(exchange, bytes); + }); + + server.createContext("/.well-known/agent-card.json", exchange -> { + byte[] body = ("{\"name\":\"a\",\"description\":\"d\",\"version\":\"1\"," + + "\"url\":\"http://127.0.0.1/\"}").getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + + server.createContext("/echo-accept-encoding", exchange -> { + byte[] body = String.valueOf( + exchange.getRequestHeaders().getFirst("Accept-Encoding")) + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + + server.start(); + base = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + void stopServer() { + server.stop(0); + serverExecutor.shutdownNow(); + } + + private AdcpHttpClient client() { + return AdcpHttpClient.builder() + .ssrfPolicy(SsrfPolicy.permissive()) + .maxResponseBytes(4096) + .maxCallResponseBytes(16 * 1024) + .build(); + } + + @Test + void probe_response_over_the_probe_cap_is_flagged_not_thrown() throws Exception { + try (AdcpHttpClient client = client()) { + AdcpHttpResponse response = + client.get(URI.create(base + "/chunked?bytes=20000"), Map.of()); + + assertTrue(response.truncated(), + "probe callers must be able to see that the body is incomplete"); + assertEquals(4096, response.body().length); + } + } + + @Test + void agent_card_over_the_call_cap_throws() { + try (AdcpHttpClient client = client()) { + ResponseTooLargeError error = assertThrows(ResponseTooLargeError.class, + () -> client.getForAgentCard( + URI.create(base + "/chunked?bytes=" + (64 * 1024)), Map.of())); + + assertEquals(16 * 1024, error.limit()); + assertTrue(error.bytesRead() > 16 * 1024); + } + } + + @Test + void agent_card_with_an_over_cap_content_length_is_rejected_before_reading() { + try (AdcpHttpClient client = client()) { + ResponseTooLargeError error = assertThrows(ResponseTooLargeError.class, + () -> client.getForAgentCard( + URI.create(base + "/declared?bytes=" + (64 * 1024)), Map.of())); + + assertEquals(16 * 1024, error.limit()); + assertEquals(64 * 1024, error.bytesRead(), + "the declared length should be reported verbatim — nothing was read"); + } + } + + @Test + void agent_card_under_the_call_cap_is_returned_whole() throws Exception { + try (AdcpHttpClient client = client()) { + AdcpHttpResponse response = client.getForAgentCard( + URI.create(base + "/.well-known/agent-card.json"), Map.of()); + + assertEquals(200, response.statusCode()); + assertFalse(response.truncated()); + assertTrue(response.bodyAsString().contains("\"name\":\"a\"")); + } + } + + @Test + void agent_card_between_the_probe_cap_and_the_call_cap_still_succeeds() throws Exception { + // A 4 KiB probe cap would have silently truncated this card and sent the + // A2A loader off to a synthetic fallback; the 16 KiB call cap must not. + try (AdcpHttpClient client = client()) { + AdcpHttpResponse response = client.getForAgentCard( + URI.create(base + "/declared?bytes=10000"), Map.of()); + + assertFalse(response.truncated()); + assertEquals(10000, response.body().length); + } + } + + @Test + void outbound_requests_force_identity_encoding() throws Exception { + try (AdcpHttpClient client = client()) { + URI uri = URI.create(base + "/echo-accept-encoding"); + + assertEquals("identity", + client.get(uri, Map.of("Accept-Encoding", "gzip")).bodyAsString()); + assertEquals("identity", + client.getForAgentCard(uri, Map.of("Accept-Encoding", "gzip")) + .bodyAsString()); + } + } + + @Test + void probe_and_call_caps_have_independent_defaults() { + try (AdcpHttpClient client = AdcpHttpClient.builder().build()) { + assertEquals(AdcpHttpClient.DEFAULT_MAX_RESPONSE_BYTES, client.maxResponseBytes()); + assertEquals(AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES, + client.maxCallResponseBytes()); + assertEquals(4 * 1024, client.maxResponseBytes()); + assertEquals(4 * 1024 * 1024, client.maxCallResponseBytes()); + } + } + + private static void writeFiller(HttpExchange exchange, int bytes) throws IOException { + byte[] body = new byte[bytes]; + Arrays.fill(body, (byte) 'x'); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } catch (IOException expectedWhenCapAborts) { + // the client stopped reading + } + } + + private static int intParam(HttpExchange exchange, String name) { + String query = exchange.getRequestURI().getQuery(); + if (query == null) { + return 0; + } + for (String pair : query.split("&")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2 && kv[0].equals(name)) { + return Integer.parseInt(kv[1]); + } + } + return 0; + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClientTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClientTest.java new file mode 100644 index 0000000..91127f9 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClientTest.java @@ -0,0 +1,418 @@ +package org.adcontextprotocol.adcp.http; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the streaming response-size cap that protects every MCP and A2A call + * from a memory-DoS by an oversized agent response. + * + *

    Each test drives a real loopback HTTP server through a real JDK + * {@link HttpClient}, because the whole point of the cap is that it fires + * while the body streams in — a check that only runs after the body is + * buffered would pass a unit test and still OOM the caller. + * + *

    Remove the cap and these fail: the oversized-body cases return a body + * instead of throwing. + */ +class ResponseSizeLimitingHttpClientTest { + + private static final int CAP = 64 * 1024; + + private HttpServer server; + private ExecutorService serverExecutor; + private HttpClient raw; + private String base; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + serverExecutor = Executors.newCachedThreadPool(); + server.setExecutor(serverExecutor); + + // Chunked body of `bytes` bytes, no Content-Length. + server.createContext("/chunked", exchange -> { + long bytes = longParam(exchange, "bytes", 0); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + writeBytes(exchange, bytes); + }); + + // Body of `bytes` bytes with a declared Content-Length. + server.createContext("/declared", exchange -> { + long bytes = longParam(exchange, "bytes", 0); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes); + writeBytes(exchange, bytes); + }); + + // `events` SSE events of `size` payload bytes each. + server.createContext("/sse", exchange -> { + long events = longParam(exchange, "events", 1); + long size = longParam(exchange, "size", 16); + exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, 0); + byte[] filler = new byte[(int) size]; + Arrays.fill(filler, (byte) 'x'); + try (OutputStream os = exchange.getResponseBody()) { + for (long i = 0; i < events; i++) { + os.write("data: ".getBytes(StandardCharsets.UTF_8)); + os.write(filler); + os.write("\n\n".getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + } catch (IOException expectedWhenCapAborts) { + // the client cancelled mid-stream + } + }); + + // Echoes method, body, a custom header and Accept-Encoding. + server.createContext("/echo-request", exchange -> { + String requestBody = new String(exchange.getRequestBody().readAllBytes(), + StandardCharsets.UTF_8); + byte[] body = (exchange.getRequestMethod() + + "|" + requestBody + + "|" + exchange.getRequestHeaders().getFirst("X-Custom") + + "|" + exchange.getRequestHeaders().getFirst("Accept-Encoding")) + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/plain"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + + // Echoes the request's Accept-Encoding header back as the body. + server.createContext("/accept-encoding", exchange -> { + byte[] body = String.valueOf( + exchange.getRequestHeaders().getFirst("Accept-Encoding")) + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/plain"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + + server.start(); + base = "http://127.0.0.1:" + server.getAddress().getPort(); + raw = HttpClient.newHttpClient(); + } + + @AfterEach + void stopServer() { + raw.close(); + server.stop(0); + serverExecutor.shutdownNow(); + } + + // -- the cap itself -- + + @Test + void over_cap_body_without_content_length_throws() { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + IOException thrown = assertThrows(IOException.class, + () -> get(client, "/chunked?bytes=" + (CAP * 4L))); + + ResponseTooLargeError tooLarge = ResponseTooLargeError.findIn(thrown); + assertNotNull(tooLarge, "expected a ResponseTooLargeError in the cause chain"); + assertEquals(CAP, tooLarge.limit()); + assertTrue(tooLarge.bytesRead() > CAP, + "bytesRead should report the count at the moment of the abort"); + } + + @Test + void over_cap_content_length_is_rejected_before_the_body_is_read() { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + long declared = CAP * 4L; + + IOException thrown = assertThrows(IOException.class, + () -> get(client, "/declared?bytes=" + declared)); + + ResponseTooLargeError tooLarge = ResponseTooLargeError.findIn(thrown); + assertNotNull(tooLarge); + assertEquals(CAP, tooLarge.limit()); + // The declared length is reported verbatim — proof that nothing was read. + assertEquals(declared, tooLarge.bytesRead()); + } + + @Test + void under_cap_body_succeeds() throws Exception { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + HttpResponse response = get(client, "/chunked?bytes=" + (CAP / 2)); + + assertEquals(200, response.statusCode()); + assertEquals(CAP / 2, response.body().length()); + } + + @Test + void body_exactly_at_the_cap_succeeds() throws Exception { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + HttpResponse response = get(client, "/declared?bytes=" + CAP); + + assertEquals(CAP, response.body().length()); + } + + @Test + void per_call_override_raises_the_cap() throws Exception { + long body = CAP * 2L; + HttpClient capped = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + assertThrows(IOException.class, () -> get(capped, "/chunked?bytes=" + body)); + + HttpClient raised = ResponseSizeLimitingHttpClient.wrap(raw, body * 2); + assertEquals(body, get(raised, "/chunked?bytes=" + body).body().length()); + } + + @Test + void async_sends_are_capped_too() { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + CompletableFuture> future = client.sendAsync( + HttpRequest.newBuilder(URI.create(base + "/chunked?bytes=" + (CAP * 4L))).build(), + HttpResponse.BodyHandlers.ofString()); + + ExecutionException thrown = assertThrows(ExecutionException.class, + () -> future.get(30, TimeUnit.SECONDS)); + assertNotNull(ResponseTooLargeError.findIn(thrown)); + } + + // -- SSE -- + + @Test + void single_over_cap_sse_event_throws() { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + // One event, four times the cap. A GET SSE stream has no cumulative + // cap, so only the per-event cap can catch this. + IOException thrown = assertThrows(IOException.class, + () -> get(client, "/sse?events=1&size=" + (CAP * 4L))); + + ResponseTooLargeError tooLarge = ResponseTooLargeError.findIn(thrown); + assertNotNull(tooLarge, "a single oversized SSE event must not escape the cap"); + assertEquals(CAP, tooLarge.limit()); + } + + @Test + void cumulative_sse_over_cap_throws_for_a_single_call() { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + // Eight events, each an eighth of the cap under it: no single event + // trips the per-event cap, but together they are over. + long size = CAP / 4; + IOException thrown = assertThrows(IOException.class, + () -> post(client, "/sse?events=8&size=" + size)); + + ResponseTooLargeError tooLarge = ResponseTooLargeError.findIn(thrown); + assertNotNull(tooLarge, + "a call answered as a drip-feed of small SSE events must not escape the cap"); + assertEquals(CAP, tooLarge.limit()); + } + + @Test + void sse_content_type_does_not_bypass_the_cap_on_a_call() { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + // A peer answering a POST with text/event-stream instead of + // application/json gets no extra budget. + assertThrows(IOException.class, + () -> post(client, "/sse?events=1&size=" + (CAP * 4L))); + } + + @Test + void long_lived_get_sse_stream_is_capped_per_event_not_cumulatively() throws Exception { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + // The MCP SSE session channel is a GET that stays open for the life of + // the connection and carries every server-to-client message. Capping + // its running total would kill healthy long sessions, so only the + // per-event cap applies: 8 events of a quarter-cap each (twice the cap + // in total) must stream through. + HttpResponse response = get(client, "/sse?events=8&size=" + (CAP / 4)); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().length() > CAP, + "the listening stream should be allowed past the cumulative cap"); + assertEquals(8, response.body().split("\n\n", -1).length - 1); + } + + // -- encoding -- + + @Test + void outbound_requests_ask_for_identity_encoding() throws Exception { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + // Counting compressed bytes would let a peer hide a huge payload behind + // a small gzip stream, so a caller-supplied Accept-Encoding is replaced. + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(base + "/accept-encoding")) + .header("Accept-Encoding", "gzip, deflate") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals("identity", response.body()); + } + + // -- plumbing -- + + @Test + void rewriting_accept_encoding_preserves_the_request_body_and_method() throws Exception { + // The MCP and A2A transports POST JSON-RPC payloads; the Accept-Encoding + // rewrite rebuilds the request, and dropping the body there would break + // every call while the cap tests still passed. + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + HttpResponse response = client.send( + HttpRequest.newBuilder(URI.create(base + "/echo-request")) + .header("Accept-Encoding", "gzip") + .header("X-Custom", "kept") + .POST(HttpRequest.BodyPublishers.ofString("{\"jsonrpc\":\"2.0\"}")) + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals("POST|{\"jsonrpc\":\"2.0\"}|kept|identity", response.body()); + } + + @Test + void wrap_rejects_a_non_positive_cap() { + assertThrows(IllegalArgumentException.class, + () -> ResponseSizeLimitingHttpClient.wrap(raw, 0)); + assertThrows(IllegalArgumentException.class, + () -> ResponseSizeLimitingHttpClient.wrap(raw, -1)); + } + + @Test + void wrapper_exposes_the_delegate_configuration() { + ResponseSizeLimitingHttpClient client = + ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + assertEquals(CAP, client.maxResponseBytes()); + assertEquals(raw.followRedirects(), client.followRedirects()); + assertEquals(raw.version(), client.version()); + assertEquals(raw.connectTimeout(), client.connectTimeout()); + } + + @Test + void adcp_http_client_builds_capped_transport_clients() { + AdcpHttpClient adcp = AdcpHttpClient.builder() + .maxCallResponseBytes(123_456) + .build(); + try (adcp) { + HttpClient mcp = adcp.newMcpClientBuilder().build(); + HttpClient a2a = adcp.newHttpClientBuilder().build(); + HttpClient override = adcp.newHttpClientBuilder(999).build(); + try (mcp; a2a; override) { + assertEquals(123_456, + ((ResponseSizeLimitingHttpClient) mcp).maxResponseBytes()); + assertEquals(123_456, + ((ResponseSizeLimitingHttpClient) a2a).maxResponseBytes()); + assertEquals(999, + ((ResponseSizeLimitingHttpClient) override).maxResponseBytes()); + assertEquals(HttpClient.Redirect.NEVER, mcp.followRedirects()); + } + } + } + + @Test + void adcp_http_client_rejects_a_non_positive_transport_cap() { + try (AdcpHttpClient adcp = AdcpHttpClient.builder().build()) { + assertThrows(IllegalArgumentException.class, + () -> adcp.newHttpClientBuilder(0)); + assertThrows(IllegalArgumentException.class, + () -> AdcpHttpClient.builder().maxCallResponseBytes(-1)); + } + } + + @Test + void find_in_walks_cause_and_suppressed_chains_without_looping() { + ResponseTooLargeError error = new ResponseTooLargeError(1, 2, null); + + assertNull(ResponseTooLargeError.findIn(null)); + assertEquals(error, ResponseTooLargeError.findIn(error)); + assertEquals(error, + ResponseTooLargeError.findIn(new IOException("wrapped", error))); + + IOException suppressing = new IOException("outer"); + suppressing.addSuppressed(error); + assertEquals(error, ResponseTooLargeError.findIn(suppressing)); + + // A cyclic cause chain must terminate rather than spin forever. + IOException first = new IOException("a"); + IOException second = new IOException("b"); + first.initCause(second); + second.initCause(first); + assertNull(ResponseTooLargeError.findIn(first)); + } + + // -- helpers -- + + private HttpResponse get(HttpClient client, String path) throws Exception { + return client.send(HttpRequest.newBuilder(URI.create(base + path)).build(), + HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse post(HttpClient client, String path) throws Exception { + return client.send( + HttpRequest.newBuilder(URI.create(base + path)) + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private static void writeBytes(HttpExchange exchange, long bytes) throws IOException { + byte[] chunk = new byte[8192]; + Arrays.fill(chunk, (byte) 'x'); + try (OutputStream os = exchange.getResponseBody()) { + long written = 0; + while (written < bytes) { + int n = (int) Math.min(chunk.length, bytes - written); + os.write(chunk, 0, n); + written += n; + } + } catch (IOException expectedWhenCapAborts) { + // the client cancelled mid-stream + } + } + + private static long longParam(HttpExchange exchange, String name, long fallback) { + String query = exchange.getRequestURI().getQuery(); + if (query == null) { + return fallback; + } + for (String pair : query.split("&")) { + List kv = Arrays.asList(pair.split("=", 2)); + if (kv.size() == 2 && kv.get(0).equals(name)) { + return Long.parseLong(kv.get(1)); + } + } + return fallback; + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManagerTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManagerTest.java index 76bb004..78f1a46 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManagerTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManagerTest.java @@ -41,7 +41,7 @@ void getOrConnect_reuses_cached_client_for_same_url() { loaderCalls.incrementAndGet(); return testCard(agent.agentUri()); }, - agentCard -> { + (agentCard, maxResponseBytes) -> { factoryCalls.incrementAndGet(); return testClient(agentCard); }); @@ -69,7 +69,7 @@ void getOrConnect_different_auth_headers_get_separate_clients() { discoveryHeaders.add(Map.copyOf(headers)); return testCard(agent.agentUri()); }, - agentCard -> { + (agentCard, maxResponseBytes) -> { factoryCalls.incrementAndGet(); return testClient(agentCard); }); @@ -98,7 +98,7 @@ void buildCacheKey_does_not_include_authorization_header() { discoveryHeaders.add(Map.copyOf(headers)); return testCard(agent.agentUri()); }, - A2aConnectionManagerTest::testClient); + (card, maxResponseBytes) -> testClient(card)); AgentConfig agent = AgentConfig.builder() .id("a2a-agent") @@ -126,7 +126,7 @@ void evict_exact_cache_hash_removes_only_matching_variant() { AtomicInteger factoryCalls = new AtomicInteger(); manager = new A2aConnectionManager( (agent, headers) -> testCard(agent.agentUri()), - agentCard -> { + (agentCard, maxResponseBytes) -> { factoryCalls.incrementAndGet(); return testClient(agentCard); }); @@ -155,7 +155,7 @@ void evict_forces_reconnect() { AtomicInteger factoryCalls = new AtomicInteger(); manager = new A2aConnectionManager( (agent, headers) -> testCard(agent.agentUri()), - agentCard -> { + (agentCard, maxResponseBytes) -> { factoryCalls.incrementAndGet(); return testClient(agentCard); }); @@ -178,7 +178,7 @@ void evict_forces_reconnect() { void getOrConnect_after_close_throws() { manager = new A2aConnectionManager( (agent, headers) -> testCard(agent.agentUri()), - A2aConnectionManagerTest::testClient); + (card, maxResponseBytes) -> testClient(card)); manager.close(); AgentConfig agent = AgentConfig.builder() @@ -203,7 +203,7 @@ void httpAgentCardLoader_uses_agent_card_json_well_known_path() { fetchedUris.add(expected); return testCard(agent.agentUri()); }, - A2aConnectionManagerTest::testClient); + (card, maxResponseBytes) -> testClient(card)); AgentConfig agent = AgentConfig.builder() .id("a2a-agent") @@ -234,7 +234,7 @@ void authenticated_card_discovery_passes_auth_headers_to_loader() { capturedHeaders.add(Map.copyOf(headers)); return testCard(agent.agentUri()); }, - A2aConnectionManagerTest::testClient); + (card, maxResponseBytes) -> testClient(card)); AgentConfig agent = AgentConfig.builder() .id("a2a-agent") diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/ResponseSizeCapWiringTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/ResponseSizeCapWiringTest.java new file mode 100644 index 0000000..d078115 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/ResponseSizeCapWiringTest.java @@ -0,0 +1,185 @@ +package org.adcontextprotocol.adcp.transport.a2a; + +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.config.ClientConfig; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.adcontextprotocol.adcp.AgentConfig; +import org.adcontextprotocol.adcp.Protocol; +import org.adcontextprotocol.adcp.http.AdcpHttpClient; +import org.adcontextprotocol.adcp.http.SsrfPolicy; +import org.adcontextprotocol.adcp.schema.AdcpObjectMapperFactory; +import org.adcontextprotocol.adcp.transport.CallToolOptions; +import org.adcontextprotocol.adcp.transport.ProtocolClient; +import org.adcontextprotocol.adcp.transport.mcp.McpConnectionManager; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the response size cap reaches the transport that enforces it, + * and that the per-client → per-agent → per-call override order is honoured. + * + *

    The cap is baked into the transport's {@code HttpClient} at connect time, + * so "is the override wired" is answerable by recording the cap the client + * factory is handed. A regression here is silent — the call still works, it is + * just capped at the wrong number — which is exactly why it is asserted. + */ +class ResponseSizeCapWiringTest { + + /** An unroutable-but-fast-failing local port: the call fails, the cap is still recorded. */ + private static final URI AGENT_URI = URI.create("http://127.0.0.1:1/"); + + @Test + void client_default_cap_is_four_mebibytes() { + assertEquals(4L * 1024 * 1024, AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + try (AdcpHttpClient http = AdcpHttpClient.builder().build()) { + assertEquals(4L * 1024 * 1024, http.maxCallResponseBytes()); + } + } + + @Test + void default_cap_is_used_when_nothing_overrides_it() { + List caps = capsSeenBy(agent(null), CallToolOptions.DEFAULT); + + assertTrue(caps.size() >= 1, "the factory should have been asked for a client"); + assertEquals(AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES, caps.get(0)); + } + + @Test + void agent_config_overrides_the_client_default() { + List caps = capsSeenBy(agent(777_000L), CallToolOptions.DEFAULT); + + assertEquals(777_000L, caps.get(0)); + assertNotEquals(AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES, caps.get(0)); + } + + @Test + void call_options_override_the_agent_setting() { + List caps = capsSeenBy(agent(777_000L), + CallToolOptions.builder().maxResponseBytes(31_337).build()); + + assertEquals(31_337L, caps.get(0)); + } + + @Test + void connections_are_cached_per_cap() { + RecordingFactory factory = new RecordingFactory(); + try (A2aConnectionManager manager = new A2aConnectionManager( + (cfg, headers) -> card(cfg.agentUri()), factory)) { + + AgentConfig agent = agent(null); + Client first = manager.getOrConnect(agent, Map.of(), "hash", 1_000L); + Client second = manager.getOrConnect(agent, Map.of(), "hash", 1_000L); + Client third = manager.getOrConnect(agent, Map.of(), "hash", 2_000L); + + assertEquals(first, second, "same cap should reuse the cached client"); + assertNotEquals(first, third, + "a different cap must not reuse a client built for the old cap"); + assertEquals(List.of(1_000L, 2_000L), factory.caps); + } + } + + @Test + void evict_removes_every_cap_variant() { + RecordingFactory factory = new RecordingFactory(); + try (A2aConnectionManager manager = new A2aConnectionManager( + (cfg, headers) -> card(cfg.agentUri()), factory)) { + + AgentConfig agent = agent(null); + manager.getOrConnect(agent, Map.of(), "hash", 1_000L); + manager.getOrConnect(agent, Map.of(), "hash", 2_000L); + manager.evict(agent.agentUri(), "hash"); + + manager.getOrConnect(agent, Map.of(), "hash", 1_000L); + assertEquals(List.of(1_000L, 2_000L, 1_000L), factory.caps, + "both cap variants should have been evicted and rebuilt on demand"); + } + } + + @Test + void a_non_positive_override_is_rejected() { + RecordingFactory factory = new RecordingFactory(); + try (A2aConnectionManager manager = new A2aConnectionManager( + (cfg, headers) -> card(cfg.agentUri()), factory)) { + AgentConfig agent = agent(null); + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> manager.getOrConnect(agent, Map.of(), "hash", 0L)); + } + } + + // -- helpers -- + + /** + * Runs one {@code callTool} through a real {@link ProtocolClient} and + * returns the caps the client factory was handed. The call itself fails + * (nothing is listening) — the point is the cap that was resolved first. + */ + private static List capsSeenBy(AgentConfig agent, CallToolOptions options) { + RecordingFactory factory = new RecordingFactory(); + A2aConnectionManager a2a = new A2aConnectionManager( + (cfg, headers) -> card(cfg.agentUri()), factory); + try (ProtocolClient client = new ProtocolClient( + AdcpObjectMapperFactory.create(), SsrfPolicy.permissive(), null, + new McpConnectionManager(Duration.ofSeconds(1)), a2a)) { + client.callTool(agent, "get_products", Map.of(), Object.class, options); + } catch (RuntimeException expected) { + // connection refused — we only care about the resolved cap + } + return factory.caps; + } + + private static AgentConfig agent(Long maxResponseBytes) { + AgentConfig.Builder builder = AgentConfig.builder() + .id("a2a-agent") + .agentUri(AGENT_URI) + .protocol(Protocol.A2A); + if (maxResponseBytes != null) { + builder.maxResponseBytes(maxResponseBytes); + } + return builder.build(); + } + + private static AgentCard card(URI uri) { + return AgentCard.builder() + .name("test") + .description("test agent") + .version("1.0") + .url(uri.toString()) + .preferredTransport("JSONRPC") + .capabilities(AgentCapabilities.builder() + .streaming(false).pushNotifications(false).build()) + .supportedInterfaces(List.of(new AgentInterface("JSONRPC", uri.toString()))) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .build(); + } + + private static final class RecordingFactory implements A2aConnectionManager.ClientFactory { + private final List caps = new ArrayList<>(); + + @Override + public Client create(AgentCard agentCard, long maxResponseBytes) + throws A2AClientException { + caps.add(maxResponseBytes); + return Client.builder(agentCard) + .clientConfig(ClientConfig.builder() + .setStreaming(false).setUseClientPreference(true).build()) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()) + .build(); + } + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManagerTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManagerTest.java index 0e57d30..06badba 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManagerTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManagerTest.java @@ -49,6 +49,33 @@ void getOrConnect_after_close_throws() { () -> manager.getOrConnect(uri, java.util.Map.of(), "hash")); } + @Test + void default_response_cap_is_the_client_default() { + assertEquals( + org.adcontextprotocol.adcp.http.AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES, + manager.defaultMaxResponseBytes()); + } + + @Test + void custom_http_client_cap_is_propagated() { + var http = org.adcontextprotocol.adcp.http.AdcpHttpClient.builder() + .maxCallResponseBytes(123_456) + .build(); + try (http; var mgr = new McpConnectionManager( + java.time.Duration.ofSeconds(1), java.time.Duration.ofSeconds(1), http)) { + assertEquals(123_456, mgr.defaultMaxResponseBytes()); + } + } + + @Test + void getOrConnect_rejects_a_non_positive_cap_override() { + var uri = java.net.URI.create("https://agent.example.com"); + assertThrows(IllegalArgumentException.class, + () -> manager.getOrConnect(uri, java.util.Map.of(), "hash", 0L)); + assertThrows(IllegalArgumentException.class, + () -> manager.getOrConnect(uri, java.util.Map.of(), "hash", -1L)); + } + @Test void double_close_is_safe() { manager.close(); diff --git a/specs/response-size-limits.md b/specs/response-size-limits.md new file mode 100644 index 0000000..2e38b23 --- /dev/null +++ b/specs/response-size-limits.md @@ -0,0 +1,137 @@ +# Response size limits + +**Status:** Shipped — closes [#39](https://github.com/adcontextprotocol/adcp-sdk-java/issues/39) +**Tracks:** [`transport`](../ROADMAP.md#track-3--l0-transport-mcp--a2a) +**Decisions referenced:** D2 (JDK 21), D9 (MCP SDK pin), D10 (A2A SDK pin), D24 +**Cross-SDK tracking:** [adcontextprotocol/adcp#8190](https://github.com/adcontextprotocol/adcp/issues/8190) + +## Why this exists + +An AdCP agent's response is untrusted input. A hosted compliance grader was repeatedly OOM-killed +when a seller agent answered a single `get_products` call with a 17.8 MB payload: process memory +went past 10 GB, almost all of it outside the JS heap, and every agent graded in the same batch +starved. With a 4 MiB response cap the same run peaks at 1.6 GB. + +A buyer, crawler or grader built on an AdCP SDK should not be memory-DoS-able by one agent, and +that protection must not depend on every adopter remembering to opt in. So the cap is **on by +default**, in every SDK, at the same number. + +The Java-specific problem is that the SDK does not own the sockets on the call paths. MCP calls go +through `HttpClientStreamableHttpTransport` / `HttpClientSseClientTransport` and A2A calls through +`JdkA2AHttpClient` — all third-party, none of which exposes a body-size hook. What they *do* accept +is a JDK `java.net.http.HttpClient`. That is the choke point this design uses. + +## Defaults + +| Scope | Default | Where to change it | +|---|---|---| +| Agent responses (MCP `callTool`, A2A `message/send`, SSE, `tools/list`, agent card) | **4 MiB** (`4 * 1024 * 1024`) | `AdcpClient.Builder.maxResponseBytes(long)` | +| Per agent | inherits the client default | `AgentConfig.Builder.maxResponseBytes(long)` | +| Per call | inherits the agent default | `CallToolOptions.Builder.maxResponseBytes(long)` | +| Discovery probes (transport detection, `WWW-Authenticate` challenge) | 4 KiB | `AdcpHttpClient.Builder.maxResponseBytes(long)` | + +Resolution order for an agent response is **call → agent → client**. There is no opt-out: every +setter rejects a non-positive value. An adopter with a genuinely large catalogue raises the number; +nobody turns the cap off. + +```java +// Per client +AdcpClient client = AdcpClient.builder() + .agent(agent) + .maxResponseBytes(32L * 1024 * 1024) + .build(); + +// Per agent +AgentConfig agent = AgentConfig.builder() + .id("big-catalogue-seller") + .agentUri(URI.create("https://agent.example.com")) + .maxResponseBytes(32L * 1024 * 1024) + .build(); + +// Per call +client.callTool("get_products", args, GetProductsResponse.class, + CallToolOptions.builder().maxResponseBytes(32L * 1024 * 1024).build()); +``` + +## The error + +An over-cap response throws +[`ResponseTooLargeError`](../adcp/src/main/java/org/adcontextprotocol/adcp/error/ResponseTooLargeError.java) +(`code() == "RESPONSE_TOO_LARGE"`), carrying `limit()`, `bytesRead()` and `url()`. Nothing is ever +truncated on a path whose body is parsed. + +The cap fires inside a `java.net.http` body subscriber, so the JDK — and then the MCP/A2A +transports — may wrap it before it reaches the caller. Two things follow: + +- Callers who want to branch on it should use `ResponseTooLargeError.findIn(throwable)`, which walks + the cause and suppressed chains with a depth bound. +- The SDK's own transport code unwraps it (`McpCaller`, `A2aCaller`, `McpConnectionManager`, + `A2aConnectionManager`) so that `ProtocolClient`'s evict-and-retry path never re-downloads an + oversized payload as if it were a transient I/O blip. + +## How enforcement works + +[`ResponseSizeLimitingHttpClient`](../adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java) +decorates a JDK `HttpClient`. `AdcpHttpClient.newHttpClientBuilder(long)` returns a +`HttpClient.Builder` that builds one, so the MCP transports (`clientBuilder(...)`) and +`JdkA2AHttpClient` get a capped client without knowing it. Four properties: + +1. **Pre-reject on `Content-Length`.** A declared length over the cap fails before one body byte is + read. +2. **Count while reading.** Bytes are counted in `Flow.Subscriber.onNext`; the subscription is + cancelled the instant the cap is crossed. A check after the body is buffered or parsed would not + prevent the OOM this exists to stop. +3. **SSE per event and per call.** For `Content-Type: text/event-stream`, bytes are counted per + event (reset at each blank-line delimiter) *and* cumulatively for the whole response when the + response answers a single call — any non-`GET` request. Answering a POST with + `text/event-stream` buys a peer no extra budget. +4. **Decompressed bytes.** Outbound requests are rewritten to `Accept-Encoding: identity`, + replacing any caller-supplied value, so a peer cannot hide a 1 GiB payload behind a small gzip + stream. (The JDK `HttpClient` never decompresses transparently, so wire bytes *are* the bytes the + caller holds.) + +### The one deliberate asymmetry: long-lived `GET` SSE streams + +The MCP SSE session channel is a `GET` that stays open for the life of the connection and carries +every server-to-client message. A cumulative cap there would kill healthy long sessions after +enough legitimate traffic — so that stream gets the **per-event cap only**. + +This is the right trade for the threat being defended against. OOM comes from how much is held at +once, and the transports aggregate one SSE event at a time, discarding bytes in between: per-event +is the cap that bounds memory. Cumulative capping is about bounding a *single call*, and a listening +stream is not a call. Every path that *is* a call — MCP StreamableHTTP POST, A2A +`message/send`, A2A streaming POST — is capped cumulatively as well as per event. + +### Probes keep their own, smaller cap + +`AdcpHttpClient.send` / `get` / `post` are discovery probes: a POST `ping` to detect StreamableHTTP +support, and HEAD/OPTIONS to read a `WWW-Authenticate` challenge. They use only the status line and +headers. Those keep the 4 KiB cap from `specs/ssrf-baseline.md` and stop reading rather than throw, +flagging the result via `AdcpHttpResponse.truncated()` — not silent, and no caller parses the body. + +`getForAgentCard` *is* parsed, so it moved to the strict 4 MiB call cap. Before this change it ran +on the 4 KiB probe cap and an agent card larger than 4 KiB — easily reached with a handful of skills +— was silently truncated and swapped for a synthetic fallback card. + +## Connection caching + +The cap is baked into the transport's `HttpClient` at connect time, so it is part of the connection +cache key: `agentUrl::tokenHash::maxResponseBytes` (MCP) and `agentUri#cacheHash#maxResponseBytes` +(A2A). A call that overrides the cap gets a connection built for that cap rather than silently +reusing one built for another. Varying the cap per call therefore fragments the connection cache — +prefer setting it once on the `AgentConfig`. `A2aConnectionManager` pools at most 8 distinct capped +HTTP clients and builds unpooled ones beyond that, so a caller that varies the cap wildly cannot +grow an unbounded pool. + +## Cross-SDK contract + +Behaviour that must stay identical across the TypeScript, Python, Go and Java SDKs +([adcp#8190](https://github.com/adcontextprotocol/adcp/issues/8190)): + +1. On by default for every agent response on the MCP and A2A call paths, including discovery and + tool listing. Default 4 MiB. Configurable per client, per agent and per call. +2. Oversized responses fail with a typed, documented error named `ResponseTooLarge…` per language + convention. Never a silent truncation. +3. Enforced while reading, with a `Content-Length` pre-reject. +4. SSE covered per event and cumulatively per call. +5. Decompressed bytes counted, or identity encoding forced. diff --git a/specs/ssrf-baseline.md b/specs/ssrf-baseline.md index de60ac9..f019409 100644 --- a/specs/ssrf-baseline.md +++ b/specs/ssrf-baseline.md @@ -19,7 +19,7 @@ The Java SDK has to match this baseline on every outbound discovery call — **n | Loopback service abuse | `http://localhost:9200/` Elasticsearch, `http://127.0.0.1:8500/v1/agent/services` Consul | | DNS rebinding | Domain that resolves to a public IP at TXT-record time and to `127.0.0.1` at connect time | | Redirect-to-internal | Public agent returns `Location: http://10.0.0.1/`; client follows | -| Slow-loris / huge-body | Public agent returns a 10 GB body to exhaust the prober's memory | +| Slow-loris / huge-body | Public agent returns a 10 GB body to exhaust the prober's memory (see [`response-size-limits.md`](response-size-limits.md)) | The first four classes target *what we connect to.* The fifth bypasses static URL validation by deferring the attack to redirect-follow time. The sixth weaponizes the prober's resource budget. @@ -28,7 +28,7 @@ The first four classes target *what we connect to.* The fifth bypasses static UR 1. **Resolve DNS once, validate the full address set.** For an outbound URL whose host is a name, resolve to the full address list, run every address through the address-guard, accept the call **only if all addresses pass**. Defense-in-depth against partial-block lists (host with one public + one private A record). 2. **Pin the connect to the first validated address.** Bypass `HttpClient`'s implicit re-resolution. Defense against DNS rebinding: even if the name's records change between resolution and connect, we connect to the IP we already validated. 3. **`redirect: 'manual'`.** No transparent redirect-follow. A 3xx from a probe is a result, not an action. Callers that need a follow re-run validation against the new URL. -4. **Body cap.** 4 KiB by default on probes (the agent-card / `tools/list` size class). Configurable per call site. +4. **Body cap.** 4 KiB by default on probes — transport detection and `WWW-Authenticate` challenges, where only the status line and headers are read. Configurable per call site. Paths whose body is *parsed* (agent cards, and every MCP/A2A call response) use the separate, strict 4 MiB cap from [`response-size-limits.md`](response-size-limits.md), which throws rather than truncating. ## Address guards (block list) From ec64b199431a358e9e190cdc107c0cfc048bde7b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 14 Oct 2026 02:44:09 +0000 Subject: [PATCH 2/3] fix(transport): close the gaps expert review found in the response cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/cap-agent-response-size.md | 6 +- ROADMAP.md | 2 +- .../adcp/error/ResponseTooLargeError.java | 28 ++- .../adcp/http/AdcpHttpClient.java | 133 ++++++++++--- .../http/ResponseSizeLimitingHttpClient.java | 125 +++++++++--- .../adcp/transport/ProtocolClient.java | 3 +- .../adcp/transport/a2a/A2aCaller.java | 32 ++- .../transport/a2a/A2aConnectionManager.java | 183 +++++++++++++----- .../adcp/transport/mcp/McpCaller.java | 14 +- .../transport/mcp/McpConnectionManager.java | 32 ++- .../adcp/http/AdcpHttpClientBodyCapTest.java | 94 +++++++++ .../ResponseSizeLimitingHttpClientTest.java | 83 +++++++- .../a2a/A2aConnectionManagerTest.java | 16 +- .../a2a/ResponseSizeCapWiringTest.java | 48 ++++- docs/response-size-limits.md | 106 ++++++++++ specs/response-size-limits.md | 63 ++++-- 16 files changed, 816 insertions(+), 152 deletions(-) create mode 100644 docs/response-size-limits.md diff --git a/.changeset/cap-agent-response-size.md b/.changeset/cap-agent-response-size.md index c90ddce..1f0bcca 100644 --- a/.changeset/cap-agent-response-size.md +++ b/.changeset/cap-agent-response-size.md @@ -47,6 +47,10 @@ Also in this change: instead of silently truncating — a card over 4 KiB used to be swapped for a synthetic fallback. - MCP and A2A connection caches are keyed by the response cap, so a call that overrides it does not reuse a connection built for a different one. +- Agent-card fetches get their own read deadline. `HttpRequest.timeout` only covers status line and + headers, so a peer that sends headers and then drips or stalls could otherwise pin a thread for as + long as it liked. -Defaults, overrides and the SSE rules are documented in `specs/response-size-limits.md`. +Defaults, overrides and the SSE rules are documented in `docs/response-size-limits.md`, with the +design in `specs/response-size-limits.md`. Cross-SDK contract: https://github.com/adcontextprotocol/adcp/issues/8190 diff --git a/ROADMAP.md b/ROADMAP.md index 8aa468f..cb34824 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ Decisions made post-RFC that supersede or refine the merged text. The table belo | D21 | Branch protection on `main` | **Required: 1 code-owner approving review, required CI checks (`build`, `test`, `storyboard`, `IPR Policy / Signature`), no force-push, no direct push, no admin bypass.** Dependabot patch PRs auto-merge after green CI. Two-reviewer gate doesn't add safety with a founder pair; revisit when contributor base grows. | (Sets a repo convention) | | D22 | Multi-tenant signing keys | **Signing key selection is tenant-aware, not just `adcp_use`-aware.** The L1 signing SPI must select by purpose plus caller/resolved publisher tenant context, a JOSE-compatible pattern where one operator can publish multiple tenant keys on a single JWKS endpoint: same `adcp_use`, distinct `kid` per tenant. v0.2 freezes the explicit parameter set for `SigningContext` — `AdcpUse use()`, nullable tenant identity, nullable principal reference — and the shipped API is `SigningContext`-based with no single-arg `SigningProvider.forUse(AdcpUse)` short form. v0.3 wires tenant resolution from `AccountStore` / `adagents.json` into that selector. Longer-form shape lives in [`specs/signing-context.md`](specs/signing-context.md). | Refines RFC §L1 signing and §L2 multi-tenant principal resolution | | D23 | AdCP 3.1 beta parity target | **Track the signed protocol bundle as the source of truth; use TS/Python as implementation references.** For this roadmap update the pinned target is `3.1.0-beta.5` (2026-05-26). If 3.1 GA cuts before the v0.1 codegen PR, bump to GA deliberately in that PR; otherwise beta.5 is the target. `@adcp/sdk@8.1.0-beta.13` is aligned to beta.5; Python `6.3.0b4` is on beta.4 and is a reference, not a blocker. Java's local `ADCP_VERSION` remains `3.0.11`; v0.1 must include wire-version negotiation, multi-bundle schema loading, and 3.1 beta/GA codegen coverage before claiming cross-SDK parity. | Updates RFC parity baseline and D15 spec-rev SLO | -| D24 | Agent response size cap | **Agent responses are capped by default at 4 MiB, enforced while the body streams in, throwing `ResponseTooLargeError`.** Covers MCP `callTool`, A2A `message/send`, SSE (per event and cumulatively per call), `tools/list` and agent-card fetches. Overridable per client, per agent and per call; never disablable. Enforced by wrapping the JDK `HttpClient` handed to the third-party MCP/A2A transports, which expose no body-size hook of their own. Discovery probes keep their separate 4 KiB cap from [`specs/ssrf-baseline.md`](specs/ssrf-baseline.md). Full shape in [`specs/response-size-limits.md`](specs/response-size-limits.md); cross-SDK contract in [adcp#8190](https://github.com/adcontextprotocol/adcp/issues/8190). | Extends RFC §SSRF baseline body cap from probes to call paths | +| D24 | Agent response size cap | **Agent responses are capped by default at 4 MiB, enforced while the body streams in, throwing `ResponseTooLargeError`.** Covers MCP `callTool`, A2A `message/send`, SSE (per event and cumulatively per call), `tools/list` and agent-card fetches. Overridable per client, per agent and per call; never disablable. Enforced by wrapping the JDK `HttpClient` handed to the third-party MCP/A2A transports, which expose no body-size hook of their own. Discovery probes keep their separate 4 KiB cap from [`specs/ssrf-baseline.md`](specs/ssrf-baseline.md). Full shape in [`specs/response-size-limits.md`](specs/response-size-limits.md), adopter guide in [`docs/response-size-limits.md`](docs/response-size-limits.md); cross-SDK contract in [adcp#8190](https://github.com/adcontextprotocol/adcp/issues/8190). | Extends RFC §SSRF baseline body cap from probes to call paths | ## Parity baseline (as of 2026-05-26) diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/error/ResponseTooLargeError.java b/adcp/src/main/java/org/adcontextprotocol/adcp/error/ResponseTooLargeError.java index ca6f22e..0e5bf3a 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/error/ResponseTooLargeError.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/error/ResponseTooLargeError.java @@ -3,6 +3,7 @@ import org.jspecify.annotations.Nullable; import java.net.URI; +import java.net.URISyntaxException; /** * Response body exceeded the configured maximum size. @@ -32,13 +33,32 @@ public final class ResponseTooLargeError extends AdcpError { public ResponseTooLargeError(long limit, long bytesRead, @Nullable URI url) { super("RESPONSE_TOO_LARGE", "Response exceeded " + limit + " bytes (read " + bytesRead + ")" - + (url != null ? " from " + url : ""), + + (url != null ? " from " + redact(url) : ""), null); this.limit = limit; this.bytesRead = bytesRead; this.url = url; } + /** + * Strips userinfo and query before the URL goes into the message. + * + *

    This message is logged, surfaced to users and often fed to an LLM, and + * agent URIs routinely carry credentials in both places. {@link #url()} + * still returns the URI the caller configured. + */ + private static URI redact(URI url) { + if (url.getRawUserInfo() == null && url.getRawQuery() == null) { + return url; + } + try { + return new URI(url.getScheme(), null, url.getHost(), url.getPort(), + url.getPath(), null, null); + } catch (URISyntaxException e) { + return URI.create(String.valueOf(url.getScheme()) + "://" + url.getHost()); + } + } + /** * Finds a {@code ResponseTooLargeError} in a throwable's cause chain. * @@ -86,7 +106,11 @@ public long bytesRead() { return bytesRead; } - /** The URL that produced the oversized response, if known. */ + /** + * The URL that produced the oversized response, if known — as configured, + * including any userinfo or query. {@link #getMessage()} carries a redacted + * form instead. + */ public @Nullable URI url() { return url; } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/http/AdcpHttpClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/http/AdcpHttpClient.java index 66e43af..1298f64 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/http/AdcpHttpClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/http/AdcpHttpClient.java @@ -13,9 +13,13 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.net.URISyntaxException; import java.time.Duration; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; /** * SSRF-safe HTTP client for AdCP outbound requests. @@ -243,7 +247,27 @@ public AdcpHttpResponse get(URI uri, Map headers) */ public AdcpHttpResponse getForAgentCard(URI uri, Map headers) throws IOException, InterruptedException { + return getForAgentCard(uri, headers, maxCallResponseBytes); + } + + /** + * Fetches an agent card with an explicit response cap, so a per-agent or + * per-call override applies to discovery as well as to the call itself. + * + * @param uri target URI (SSRF-validated) + * @param headers headers to forward, including any auth material + * @param maxResponseBytes response cap in bytes; must be positive + * @throws org.adcontextprotocol.adcp.error.ResponseTooLargeError if the card + * exceeds {@code maxResponseBytes} + */ + public AdcpHttpResponse getForAgentCard(URI uri, Map headers, + long maxResponseBytes) + throws IOException, InterruptedException { Objects.requireNonNull(uri, "uri"); + if (maxResponseBytes <= 0) { + throw new IllegalArgumentException( + "maxResponseBytes must be positive: " + maxResponseBytes); + } if (requireHttps && "http".equalsIgnoreCase(uri.getScheme())) { String host = uri.getHost(); @@ -280,7 +304,7 @@ public AdcpHttpResponse getForAgentCard(URI uri, Map headers) HttpResponse.BodyHandlers.ofInputStream()); try { - return readBodyWithCap(response, maxCallResponseBytes, true, validatedUri); + return readBodyWithCap(response, maxResponseBytes, true, validatedUri); } catch (Throwable t) { try { response.body().close(); @@ -352,7 +376,11 @@ public HttpClient.Builder newHttpClientBuilder() { * {@link ResponseSizeLimitingHttpClient}: every response it reads is * counted as it streams and aborted with * {@link org.adcontextprotocol.adcp.error.ResponseTooLargeError} once the - * cap is crossed. + * cap is crossed. One shape is treated specially — a {@code GET} answered + * with {@code text/event-stream} is a long-lived listening stream and gets + * a larger cumulative budget with the same per-event cap. See + * {@link ResponseSizeLimitingHttpClient} for why, and for what that means + * if you hand this builder to a transport of your own. * * @param maxResponseBytes response cap in bytes; must be positive */ @@ -443,6 +471,13 @@ private static boolean isLoopback(String host) { * {@link ResponseTooLargeError}; otherwise (probe paths, where the body is * not parsed) reading stops at the cap and the result is flagged via * {@link AdcpHttpResponse#truncated()}. + * + *

    The read is also bounded in time. {@code HttpRequest.timeout} + * only covers the status line and headers, so without this a peer that + * sends headers and then drips — or stalls outright — would pin the calling + * thread and its socket for as long as it liked. A watchdog closes the + * stream at {@code readTimeout}, which unblocks a stalled {@code read()}, + * and the loop checks the same deadline so a drip-feed gets a clear error. */ private AdcpHttpResponse readBodyWithCap(HttpResponse response, long cap, boolean strict, URI uri) @@ -456,38 +491,78 @@ private AdcpHttpResponse readBodyWithCap(HttpResponse response, boolean truncated = false; long totalRead = 0; + long deadline = System.nanoTime() + readTimeout.toNanos(); try (InputStream is = response.body()) { - ByteArrayOutputStream baos = new ByteArrayOutputStream( - (int) Math.min(cap, 8192)); - byte[] buf = new byte[8192]; - int n; - while ((n = is.read(buf)) != -1) { - totalRead += n; - if (totalRead <= cap) { - baos.write(buf, 0, n); - continue; - } - if (strict) { - throw new ResponseTooLargeError(cap, totalRead, uri); - } - // Probe path: keep the bytes up to the cap, flag, stop reading. - int remaining = (int) (cap - (totalRead - n)); - if (remaining > 0) { - baos.write(buf, 0, remaining); + // Runs on the JDK's shared delayed-task thread; cancelling before it + // fires stops it from running at all. + CompletableFuture watchdog = CompletableFuture.runAsync( + () -> closeQuietly(is), + CompletableFuture.delayedExecutor( + readTimeout.toMillis(), TimeUnit.MILLISECONDS)); + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream( + (int) Math.min(cap, 8192)); + byte[] buf = new byte[8192]; + int n; + while ((n = is.read(buf)) != -1) { + if (System.nanoTime() - deadline > 0) { + throw new HttpTimeoutException( + "Response body from " + redact(uri) + " not delivered within " + + readTimeout + " (read " + totalRead + " bytes)"); + } + totalRead += n; + if (totalRead <= cap) { + baos.write(buf, 0, n); + continue; + } + if (strict) { + throw new ResponseTooLargeError(cap, totalRead, uri); + } + // Probe path: keep the bytes up to the cap, flag, stop reading. + int remaining = (int) (cap - (totalRead - n)); + if (remaining > 0) { + baos.write(buf, 0, remaining); + } + truncated = true; + log.debug("Probe response body truncated at {} bytes (cap={})", + totalRead, cap); + break; } - truncated = true; - log.debug("Probe response body truncated at {} bytes (cap={})", - totalRead, cap); - break; + + return new AdcpHttpResponse( + response.statusCode(), + response.headers(), + baos.toByteArray(), + truncated, + totalRead); + } finally { + watchdog.cancel(false); } + } + } + + private static void closeQuietly(InputStream stream) { + try { + stream.close(); + } catch (IOException e) { + log.debug("Error closing response body on read timeout: {}", e.toString()); + } + } - return new AdcpHttpResponse( - response.statusCode(), - response.headers(), - baos.toByteArray(), - truncated, - totalRead); + /** + * Strips userinfo and query from a URI before it lands in an exception + * message or a log line — both routinely carry credentials. + */ + static URI redact(URI uri) { + if (uri.getRawUserInfo() == null && uri.getRawQuery() == null) { + return uri; + } + try { + return new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), + uri.getPath(), null, null); + } catch (URISyntaxException e) { + return URI.create(String.valueOf(uri.getScheme()) + "://" + uri.getHost()); } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java index 72b7d78..60e98f3 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java @@ -51,15 +51,11 @@ * moment the cap is crossed, so the oversized body is never buffered. *

  • SSE per event and per call. For * {@code Content-Type: text/event-stream}, bytes are counted per event - * (reset at each blank-line delimiter) so one huge event cannot escape - * the cap, and cumulatively for the whole response when the - * response answers a single call (any non-{@code GET} request). A - * long-lived {@code GET} listening stream — the MCP SSE session channel, - * which carries every server-to-client message for the life of the - * connection — gets the per-event cap only, because a cumulative cap - * there would kill healthy long sessions. Per-event is the cap that - * matters for memory: the transports aggregate one event at a time and - * discard the bytes in between.
  • + * (reset at each blank-line delimiter, accepting CR, LF and CRLF framing) + * so one huge event cannot escape the cap, and cumulatively for + * the whole response. Answering a call with {@code text/event-stream} + * instead of {@code application/json} therefore buys no extra budget, and + * neither does drip-feeding small events. *
  • Decompressed bytes. Outbound requests are rewritten to * {@code Accept-Encoding: identity} so the counted wire bytes are the * bytes the caller will hold. The JDK {@link HttpClient} never @@ -67,12 +63,38 @@ * behind a 1 MiB gzip stream.
  • * * + *

    Long-lived listening streams

    + * + *

    One response shape gets a larger cumulative budget: a {@code GET} answered + * with {@code text/event-stream}. That is the MCP SSE session channel, which + * stays open for the life of the connection and carries every server-to-client + * message, so holding its running total to a single call's cap would kill + * healthy long sessions. Its per-event cap is unchanged — and per-event is the + * cap that bounds memory there, because the transports aggregate one event at a + * time and discard the bytes in between. Its cumulative budget is + * {@code maxResponseBytes * }{@value #LISTENING_STREAM_MULTIPLIER}, finite so + * that a peer cannot hold a {@code GET} open indefinitely simply by choosing an + * SSE content type. + * + *

    Reporting

    + * *

    Oversized responses fail the response future with * {@link ResponseTooLargeError} — never truncated, never silently dropped. * Because the JDK may wrap a body-subscriber failure before it reaches the * caller, use {@link ResponseTooLargeError#findIn(Throwable)} to recover the * typed error from a cause chain. * + *

    With a streaming body handler ({@code BodyHandlers.ofInputStream}, + * {@code ofPublisher}) the response future completes normally at the headers and + * the error surfaces on the first read past the cap instead. The SDK's own paths + * use aggregating handlers; callers wrapping their own client should keep that + * in mind. + * + *

    Scope. The cap is per in-flight response. It bounds what one peer + * can make this process hold for one call — it is not a global memory budget, + * so a caller fanning out across many agents still has to bound its own + * concurrency. + * * @see AdcpHttpClient#newHttpClientBuilder(long) * @see ResponseTooLargeError */ @@ -81,6 +103,18 @@ public final class ResponseSizeLimitingHttpClient extends HttpClient { private static final Logger log = LoggerFactory.getLogger(ResponseSizeLimitingHttpClient.class); + /** + * How much more cumulative traffic a long-lived {@code GET} event stream may + * carry than a single call response, before it is torn down anyway. + * + *

    The MCP SSE session channel stays open for the life of the connection, + * so capping its running total at the per-call cap would kill healthy long + * sessions. It still gets a finite ceiling: without one, a peer could hold + * a {@code GET} open forever by answering with {@code text/event-stream}, + * and that choice would be the peer's to make, not ours. + */ + public static final int LISTENING_STREAM_MULTIPLIER = 256; + private static final String SSE_CONTENT_TYPE = "text/event-stream"; private static final String ACCEPT_ENCODING = "Accept-Encoding"; private static final String IDENTITY = "identity"; @@ -101,6 +135,12 @@ private ResponseSizeLimitingHttpClient(HttpClient delegate, long maxResponseByte * Wraps {@code delegate} so that every response it produces is capped at * {@code maxResponseBytes}. * + *

    The cap applies per response: to the whole body, to each SSE event, + * and cumulatively across a response's events. A {@code GET} answered with + * {@code text/event-stream} is treated as a long-lived listening stream and + * gets {@value #LISTENING_STREAM_MULTIPLIER}× the cumulative budget, with + * its per-event cap unchanged — see the class javadoc. + * * @param delegate the client to decorate * @param maxResponseBytes cap in bytes; must be positive * @return the decorated client @@ -263,9 +303,11 @@ private HttpResponse.BodyHandler limiting( new ResponseTooLargeError(cap, declared, uri)); } boolean sse = isEventStream(responseInfo); + long totalCap = sse && listeningStream + ? saturatingMultiply(cap, LISTENING_STREAM_MULTIPLIER) + : cap; return new LimitingBodySubscriber<>( - delegateHandler.apply(responseInfo), - cap, sse, !(sse && listeningStream), uri); + delegateHandler.apply(responseInfo), cap, totalCap, sse, uri); }; } @@ -279,6 +321,14 @@ private HttpResponse.BodyHandler limiting( handler -> acceptor.apply(limiting(pushPromiseRequest, handler))); } + private static long saturatingMultiply(long value, int factor) { + try { + return Math.multiplyExact(value, (long) factor); + } catch (ArithmeticException overflow) { + return Long.MAX_VALUE; + } + } + private static long declaredContentLength(HttpResponse.ResponseInfo responseInfo) { try { return responseInfo.headers().firstValueAsLong("content-length").orElse(-1L); @@ -350,8 +400,8 @@ private static final class LimitingBodySubscriber private final HttpResponse.BodySubscriber delegate; private final long maxBytes; + private final long maxTotalBytes; private final boolean sse; - private final boolean enforceTotal; private final @Nullable URI uri; private final CompletableFuture body = new CompletableFuture<>(); private final AtomicBoolean aborted = new AtomicBoolean(); @@ -364,11 +414,11 @@ private static final class LimitingBodySubscriber private volatile boolean pendingCr; LimitingBodySubscriber(HttpResponse.BodySubscriber delegate, long maxBytes, - boolean sse, boolean enforceTotal, @Nullable URI uri) { + long maxTotalBytes, boolean sse, @Nullable URI uri) { this.delegate = Objects.requireNonNull(delegate, "delegate"); this.maxBytes = maxBytes; + this.maxTotalBytes = maxTotalBytes; this.sse = sse; - this.enforceTotal = enforceTotal; this.uri = uri; delegate.getBody().whenComplete((value, failure) -> { if (failure != null) { @@ -392,12 +442,12 @@ public void onNext(List buffers) { } for (ByteBuffer buffer : buffers) { totalBytes += buffer.remaining(); - if (enforceTotal && totalBytes > maxBytes) { - abort(totalBytes); + if (totalBytes > maxTotalBytes) { + abort(maxTotalBytes, totalBytes); return; } if (sse && scanForOversizedEvent(buffer)) { - abort(eventBytes); + abort(maxBytes, eventBytes); return; } } @@ -440,25 +490,38 @@ private boolean scanForOversizedEvent(ByteBuffer buffer) { for (int i = buffer.position(); i < buffer.limit(); i++) { byte b = buffer.get(i); bytes++; + + // SSE accepts CR, LF and CRLF as line terminators. A pending CR + // always terminated its line; whether the current byte joins it + // (LF), starts another terminator (CR), or is content is a + // separate question — hence two independent flags. + boolean endedLine; + boolean contentByte; if (cr) { - cr = false; - runOfTerminators++; // the CR ended a line - if (b == '\r') { - cr = true; - } else if (b != '\n') { // CRLF counts as one terminator - runOfTerminators = 0; // content: the blank-line run is broken - } + cr = b == '\r'; + endedLine = true; // the pending CR ended a line + contentByte = b != '\n' && b != '\r'; } else if (b == '\r') { cr = true; - } else if (b == '\n') { - runOfTerminators++; + endedLine = false; // wait for the next byte + contentByte = false; } else { + endedLine = b == '\n'; + contentByte = !endedLine; + } + + boolean eventEnded = false; + if (endedLine && ++runOfTerminators >= 2) { + // Two terminators in a row is a blank line: end of event. runOfTerminators = 0; + bytes = contentByte ? 1 : 0; // the content byte opens the next event + eventEnded = true; } - if (runOfTerminators >= 2) { // blank line: end of event + if (contentByte) { runOfTerminators = 0; - bytes = 0; - } else if (bytes > maxBytes) { + } + + if (!eventEnded && bytes > maxBytes) { oversized = true; break; } @@ -470,11 +533,11 @@ private boolean scanForOversizedEvent(ByteBuffer buffer) { return oversized; } - private void abort(long bytesRead) { + private void abort(long limit, long bytesRead) { if (!aborted.compareAndSet(false, true)) { return; } - ResponseTooLargeError error = new ResponseTooLargeError(maxBytes, bytesRead, uri); + ResponseTooLargeError error = new ResponseTooLargeError(limit, bytesRead, uri); log.debug("Aborting response from {}: {}", uri, error.getMessage()); // Complete our own future first so the delegate's terminal signal // cannot overwrite the typed error. diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java index eb4bd1a..a29e620 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/ProtocolClient.java @@ -7,6 +7,7 @@ import org.adcontextprotocol.adcp.Protocol; import org.adcontextprotocol.adcp.auth.AuthTokenResolver; import org.adcontextprotocol.adcp.error.ProtocolError; +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; import org.adcontextprotocol.adcp.http.ProtectedHeaders; import org.adcontextprotocol.adcp.http.SsrfPolicy; import org.adcontextprotocol.adcp.transport.a2a.A2aCaller; @@ -189,7 +190,7 @@ private T callViaMcp(AgentConfig agent, String toolName, private boolean isTransportError(ProtocolError e) { // An oversized response surfaces as an IOException from the JDK HTTP // stack, but retrying it just re-downloads the same oversized payload. - if (org.adcontextprotocol.adcp.error.ResponseTooLargeError.findIn(e) != null) { + if (ResponseTooLargeError.findIn(e) != null) { return false; } // Walk the full cause chain — any I/O or timeout failure is transient diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aCaller.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aCaller.java index fb6fc92..989e398 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aCaller.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aCaller.java @@ -18,6 +18,7 @@ import org.a2aproject.sdk.spec.TextPart; import org.adcontextprotocol.adcp.error.ProtocolError; import org.adcontextprotocol.adcp.error.ResponseTooLargeError; +import org.adcontextprotocol.adcp.http.AdcpHttpClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,17 +61,33 @@ public A2aCaller(ObjectMapper objectMapper) { this.objectMapper.deactivateDefaultTyping(); } + /** + * Calls an AdCP tool over A2A with no extra headers. + * + *

    Uses the SDK's default 4 MiB cap as its content backstop, not the cap + * configured on the calling client — the transport's own cap is the + * authoritative one. Prefer + * {@link #callTool(Client, String, Map, Class, Map, long)}. + */ public T callTool(Client client, String toolName, Map args, Class responseType) { return callTool(client, toolName, args, responseType, Map.of(), - org.adcontextprotocol.adcp.http.AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); } + /** + * Calls an AdCP tool over A2A with extra headers. + * + *

    Uses the SDK's default 4 MiB cap as its content backstop, not the cap + * configured on the calling client — the transport's own cap is the + * authoritative one. Prefer + * {@link #callTool(Client, String, Map, Class, Map, long)}. + */ public T callTool(Client client, String toolName, Map args, Class responseType, Map headers) { return callTool(client, toolName, args, responseType, headers, - org.adcontextprotocol.adcp.http.AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); } /** @@ -100,7 +117,7 @@ T callTool(A2aMessageClient client, String toolName, Map args, Class responseType, Map headers) { return callTool(client, toolName, args, responseType, headers, - org.adcontextprotocol.adcp.http.AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); } T callTool(A2aMessageClient client, String toolName, @@ -260,11 +277,14 @@ private T extractFromParts(List> parts, Class responseType, continue; } // UTF-8 is never fewer bytes than chars, so a char count over - // the cap is already over the cap — reject without measuring. - if (text.length() > maxResponseBytes - || text.getBytes(StandardCharsets.UTF_8).length > maxResponseBytes) { + // the cap is already over the cap: reject without encoding. + if (text.length() > maxResponseBytes) { throw new ResponseTooLargeError(maxResponseBytes, text.length(), null); } + int textBytes = text.getBytes(StandardCharsets.UTF_8).length; + if (textBytes > maxResponseBytes) { + throw new ResponseTooLargeError(maxResponseBytes, textBytes, null); + } try { return objectMapper.readValue(text, responseType); } catch (Exception e) { diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java index 146223c..bf720a8 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManager.java @@ -12,9 +12,9 @@ import org.a2aproject.sdk.spec.AgentInterface; import org.adcontextprotocol.adcp.AgentConfig; import org.adcontextprotocol.adcp.error.ProtocolError; +import org.adcontextprotocol.adcp.error.ResponseTooLargeError; import org.adcontextprotocol.adcp.http.AdcpHttpClient; import org.adcontextprotocol.adcp.http.AdcpHttpResponse; -import org.adcontextprotocol.adcp.error.ResponseTooLargeError; import org.adcontextprotocol.adcp.http.ProtectedHeaders; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -25,6 +25,8 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -205,7 +207,7 @@ public void close() { private Client connect(AgentConfig agent, Map sanitizedHeaders, long cap) { try { - AgentCard card = agentCardLoader.load(agent, sanitizedHeaders); + AgentCard card = agentCardLoader.load(agent, sanitizedHeaders, cap); return clientFactory.create(card, cap); } catch (ProtocolError e) { throw e; @@ -335,12 +337,22 @@ private static String sanitizeForLog(String s) { } private void closeQuietly(Client client) { + if (client == null) { + return; + } try { - if (client != null) { - client.close(); - } + client.close(); } catch (Exception e) { log.debug("Error closing A2A client: {}", sanitizeLogText(e.getMessage())); + } finally { + try { + // Client.close() only closes the A2A transport; the JDK HTTP + // client underneath it belongs to the factory. + clientFactory.release(client); + } catch (RuntimeException e) { + log.debug("Error releasing A2A transport resources: {}", + sanitizeLogText(e.getMessage())); + } } } @@ -353,31 +365,32 @@ private static String sanitizeLogText(String raw) { } interface AgentCardLoader { - AgentCard load(AgentConfig agent, Map headers); + AgentCard load(AgentConfig agent, Map headers, long maxResponseBytes); } interface ClientFactory { Client create(AgentCard agentCard, long maxResponseBytes) throws A2AClientException; - /** Releases any HTTP clients the factory owns. */ + /** + * Releases transport resources the factory allocated for {@code client}. + * Called once per client, when the connection cache lets it go. + */ + default void release(Client client) { + } + + /** Releases anything the factory still owns after every client is gone. */ default void close() { } } - private static final class DefaultClientFactory implements ClientFactory { - - /** - * Distinct response caps that get their own pooled HTTP client. Bounded - * so a caller that varies {@code maxResponseBytes} per call cannot grow - * an unbounded pool of JDK HTTP clients. - */ - private static final int MAX_POOLED_CAPS = 8; + static final class DefaultClientFactory implements ClientFactory { private final AdcpHttpClient adcpHttpClient; /** * SSRF-safe HTTP clients used by the A2A JSON-RPC transport, one per - * distinct response cap. + * distinct response cap, reference-counted by the {@link Client}s using + * them. * *

    Each is backed by a {@link java.net.http.HttpClient} built with * {@code followRedirects(NEVER)}, the configured connect timeout, and a @@ -387,9 +400,18 @@ private static final class DefaultClientFactory implements ClientFactory { * pinning AgentCard URLs to the validated agent URI, this closes the * SSRF bypass that would otherwise exist in the default * {@code JdkA2AHttpClient} (which uses {@code Redirect.NORMAL}). + * + *

    A JDK HTTP client owns a selector thread and an executor, and + * neither {@code JdkA2AHttpClient} nor {@code Client.close()} releases + * it — so the factory tracks ownership itself. Clients built for the + * same cap share one (the overwhelmingly common case: every agent on + * the default), and the last one evicted takes it down. The reference + * count, not a fixed pool bound, is what keeps a caller who varies the + * cap per call from accumulating HTTP clients. */ - private final ConcurrentHashMap pooledClients = - new ConcurrentHashMap<>(); + private final Map pool = new HashMap<>(); + private final Map capByClient = new IdentityHashMap<>(); + private final Object poolLock = new Object(); DefaultClientFactory(AdcpHttpClient adcpHttpClient) { this.adcpHttpClient = Objects.requireNonNull(adcpHttpClient, "adcpHttpClient"); @@ -402,40 +424,111 @@ public Client create(AgentCard agentCard, long maxResponseBytes) .setStreaming(true) .setUseClientPreference(true) .build(); - return Client.builder(agentCard) - .clientConfig(config) - .withTransport(JSONRPCTransport.class, - new JSONRPCTransportConfigBuilder() - .httpClient(new JdkA2AHttpClient(httpClientFor(maxResponseBytes)))) - .build(); + java.net.http.HttpClient httpClient = acquire(maxResponseBytes); + boolean handedOff = false; + try { + Client client = Client.builder(agentCard) + .clientConfig(config) + .withTransport(JSONRPCTransport.class, + new JSONRPCTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient(httpClient))) + .build(); + synchronized (poolLock) { + capByClient.put(client, maxResponseBytes); + } + handedOff = true; + return client; + } finally { + if (!handedOff) { + release(maxResponseBytes); + } + } } - private java.net.http.HttpClient httpClientFor(long maxResponseBytes) { - java.net.http.HttpClient pooled = pooledClients.get(maxResponseBytes); - if (pooled != null) { - return pooled; + @Override + public void release(Client client) { + Long cap; + synchronized (poolLock) { + cap = capByClient.remove(client); } - if (pooledClients.size() >= MAX_POOLED_CAPS) { - log.warn("More than {} distinct A2A response caps in use; " - + "building an unpooled HTTP client for cap {}", - MAX_POOLED_CAPS, maxResponseBytes); - return adcpHttpClient.newHttpClientBuilder(maxResponseBytes).build(); + if (cap != null) { + release(cap.longValue()); } - return pooledClients.computeIfAbsent(maxResponseBytes, - cap -> adcpHttpClient.newHttpClientBuilder(cap).build()); } @Override public void close() { - for (java.net.http.HttpClient client : pooledClients.values()) { - try { - client.close(); - } catch (RuntimeException e) { - log.debug("Error closing pooled A2A HTTP client: {}", - sanitizeLogText(e.getMessage())); + List orphaned; + synchronized (poolLock) { + orphaned = pool.values().stream().map(PooledHttpClient::httpClient).toList(); + pool.clear(); + capByClient.clear(); + } + orphaned.forEach(DefaultClientFactory::shutdownQuietly); + } + + /** Visible for testing: how many pooled HTTP clients are still alive. */ + int pooledClientCount() { + synchronized (poolLock) { + return pool.size(); + } + } + + private java.net.http.HttpClient acquire(long maxResponseBytes) { + synchronized (poolLock) { + PooledHttpClient pooled = pool.get(maxResponseBytes); + if (pooled == null) { + pooled = new PooledHttpClient( + adcpHttpClient.newHttpClientBuilder(maxResponseBytes).build()); + pool.put(maxResponseBytes, pooled); } + pooled.references++; + return pooled.httpClient(); + } + } + + private void release(long maxResponseBytes) { + java.net.http.HttpClient unused = null; + synchronized (poolLock) { + PooledHttpClient pooled = pool.get(maxResponseBytes); + if (pooled != null && --pooled.references <= 0) { + pool.remove(maxResponseBytes); + unused = pooled.httpClient(); + } + } + if (unused != null) { + shutdownQuietly(unused); + } + } + + /** + * {@code shutdownNow()} rather than {@code close()}: this can run under + * the connection cache lock during LRU eviction, and {@code close()} + * blocks until every in-flight exchange finishes. The owning A2A client + * has already been closed by then, which matches the existing eviction + * contract — an in-flight call on an evicted connection fails with an + * IOException and {@code ProtocolClient} retries. + */ + private static void shutdownQuietly(java.net.http.HttpClient httpClient) { + try { + httpClient.shutdownNow(); + } catch (RuntimeException e) { + log.debug("Error shutting down pooled A2A HTTP client: {}", + sanitizeLogText(e.getMessage())); + } + } + + private static final class PooledHttpClient { + private final java.net.http.HttpClient httpClient; + private int references; + + PooledHttpClient(java.net.http.HttpClient httpClient) { + this.httpClient = httpClient; + } + + java.net.http.HttpClient httpClient() { + return httpClient; } - pooledClients.clear(); } } @@ -450,14 +543,16 @@ private HttpAgentCardLoader(AdcpHttpClient adcpHttpClient, ObjectMapper objectMa } @Override - public AgentCard load(AgentConfig agent, Map headers) { + public AgentCard load(AgentConfig agent, Map headers, + long maxResponseBytes) { URI cardUri = buildAgentCardUri(agent.agentUri()); try { // Use getForAgentCard so that auth headers (e.g. Authorization) are forwarded // to private agent-card endpoints. Redirect-following is already blocked by // the underlying HttpClient (followRedirects=NEVER) and SSRF validation is // enforced inside getForAgentCard, so header leakage is not possible. - AdcpHttpResponse response = adcpHttpClient.getForAgentCard(cardUri, headers); + AdcpHttpResponse response = adcpHttpClient.getForAgentCard( + cardUri, headers, maxResponseBytes); if (response.statusCode() >= 200 && response.statusCode() < 300 && !response.truncated()) { AgentCard parsed = objectMapper.readValue(response.body(), AgentCard.class); return normalize(parsed, agent.agentUri()); diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpCaller.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpCaller.java index ba8f628..45c0e72 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpCaller.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpCaller.java @@ -6,6 +6,7 @@ import io.modelcontextprotocol.spec.McpSchema; import org.adcontextprotocol.adcp.error.ProtocolError; import org.adcontextprotocol.adcp.error.ResponseTooLargeError; +import org.adcontextprotocol.adcp.http.AdcpHttpClient; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,7 +30,6 @@ public final class McpCaller { private static final Logger log = LoggerFactory.getLogger(McpCaller.class); - private final ObjectMapper objectMapper; public McpCaller(ObjectMapper objectMapper) { @@ -47,6 +47,11 @@ public McpCaller(ObjectMapper objectMapper) { * @param client the connected MCP client * @param toolName the MCP tool name (e.g. "get_products") * @param args the merged arguments (including version envelope) + *

    Uses the SDK's default 4 MiB cap as its content backstop, not the cap + * configured on the calling client — the transport's own cap is the + * authoritative one. Prefer + * {@link #callTool(McpSyncClient, String, Map, Class, long)}. + * * @param responseType the expected response type * @param response type * @return the deserialized response @@ -55,7 +60,7 @@ public McpCaller(ObjectMapper objectMapper) { public T callTool(McpSyncClient client, String toolName, Map args, Class responseType) { return callTool(client, toolName, args, responseType, - org.adcontextprotocol.adcp.http.AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); + AdcpHttpClient.DEFAULT_MAX_CALL_RESPONSE_BYTES); } /** @@ -83,8 +88,11 @@ public T callTool(McpSyncClient client, String toolName, if (tooLarge != null) { throw tooLarge; } + // The MCP SDK's exception messages carry server-supplied text; + // strip control characters before it reaches a log or an LLM. throw new ProtocolError("mcp", - "MCP callTool failed for " + toolName + ": " + e.getMessage(), e); + "MCP callTool failed for " + toolName + ": " + + sanitizeErrorText(e.getMessage()), e); } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManager.java b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManager.java index c0acdc5..a87abc9 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManager.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/transport/mcp/McpConnectionManager.java @@ -379,10 +379,12 @@ private boolean probeSupportsStreamableHttp(URI agentUri, Map he return false; } catch (InterruptedException e) { Thread.currentThread().interrupt(); - log.debug("StreamableHTTP probe interrupted for {}: {}", agentUri, e.getMessage()); + log.debug("StreamableHTTP probe interrupted for {}: {}", + agentUri, sanitizeLogText(e.getMessage())); return true; } catch (IOException e) { - log.debug("StreamableHTTP probe failed for {}: {}", agentUri, e.getMessage()); + log.debug("StreamableHTTP probe failed for {}: {}", + agentUri, sanitizeLogText(e.getMessage())); return true; } } @@ -437,6 +439,19 @@ private static boolean hasCrlf(String s) { return s.indexOf('\r') >= 0 || s.indexOf('\n') >= 0; } + /** + * Strips control characters and truncates text that originated with the + * peer, so an agent cannot inject CR/LF into a log line or into a + * {@link ProtocolError} message an adopter may feed to an LLM. + */ + static String sanitizeLogText(@Nullable String raw) { + if (raw == null || raw.isBlank()) { + return "(no detail)"; + } + String truncated = raw.length() > 256 ? raw.substring(0, 256) + "..." : raw; + return truncated.replaceAll("[\\p{Cc}]", ""); + } + // NOTE: MCP SDK 1.1.2 does not expose HTTP response headers on errors. // We work around this by sending a HEAD probe to the agent URI to // retrieve the WWW-Authenticate challenge. When the MCP SDK adds @@ -460,10 +475,10 @@ private AuthenticationRequiredError probeAndBuildAuthError(URI agentUri, Excepti } catch (InterruptedException probeEx) { Thread.currentThread().interrupt(); log.debug("Auth challenge probe interrupted for {}: {}", - agentUri, probeEx.getMessage()); + agentUri, sanitizeLogText(probeEx.getMessage())); } catch (IOException probeEx) { log.debug("Auth challenge probe failed for {}: {}", - agentUri, probeEx.getMessage()); + agentUri, sanitizeLogText(probeEx.getMessage())); } return new AuthenticationRequiredError(agentUri, challenge, null, cause); } @@ -488,8 +503,12 @@ private static void rethrowIfResponseTooLarge(Exception e) { } } + /** Depth bound so a cyclic cause chain cannot spin this loop forever. */ + private static final int MAX_CAUSE_DEPTH = 32; + private boolean isAuthError(Exception e) { - for (Throwable t = e; t != null; t = t.getCause()) { + Throwable t = e; + for (int depth = 0; t != null && depth < MAX_CAUSE_DEPTH; t = t.getCause(), depth++) { String msg = t.getMessage(); if (msg == null) continue; // Check MCP SDK's error type first @@ -497,6 +516,7 @@ private boolean isAuthError(Exception e) { if (isAuthMessage(msg)) return true; } if (isAuthMessage(msg)) return true; + if (t.getCause() == t) break; } return false; } @@ -516,7 +536,7 @@ private void closeQuietly(McpSyncClient client) { client.close(); } } catch (Exception e) { - log.debug("Error closing MCP client: {}", e.getMessage()); + log.debug("Error closing MCP client: {}", sanitizeLogText(e.getMessage())); } } } diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/http/AdcpHttpClientBodyCapTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/http/AdcpHttpClientBodyCapTest.java index e124358..f001a28 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/http/AdcpHttpClientBodyCapTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/http/AdcpHttpClientBodyCapTest.java @@ -12,6 +12,7 @@ import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Arrays; import java.util.Map; import java.util.concurrent.ExecutorService; @@ -67,6 +68,37 @@ void startServer() throws IOException { } }); + // Sends headers, then one byte per `delayMs`, forever. + server.createContext("/drip", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + try (OutputStream os = exchange.getResponseBody()) { + while (true) { + os.write('x'); + os.flush(); + Thread.sleep(50); + } + } catch (IOException | InterruptedException expectedWhenClientGivesUp) { + // the client hit its read deadline + } + }); + + // Sends headers and then nothing at all. + server.createContext("/stall", exchange -> { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + try { + Thread.sleep(30_000); + } catch (InterruptedException expected) { + Thread.currentThread().interrupt(); + } + try { + exchange.getResponseBody().close(); + } catch (IOException ignored) { + // client already gone + } + }); + server.createContext("/echo-accept-encoding", exchange -> { byte[] body = String.valueOf( exchange.getRequestHeaders().getFirst("Accept-Encoding")) @@ -170,6 +202,68 @@ void outbound_requests_force_identity_encoding() throws Exception { } } + @Test + void a_dripping_peer_cannot_hold_the_calling_thread_past_the_read_timeout() { + // HttpRequest.timeout() only covers the status line and headers. Without + // a body deadline, a peer that sends headers and then drips one byte at + // a time pins this thread and its socket for as long as it takes to + // reach the cap — at 4 MiB, weeks. + try (AdcpHttpClient client = AdcpHttpClient.builder() + .ssrfPolicy(SsrfPolicy.permissive()) + .readTimeout(Duration.ofMillis(600)) + .build()) { + URI uri = URI.create(base + "/drip"); + long start = System.nanoTime(); + + assertThrows(IOException.class, () -> client.getForAgentCard(uri, Map.of())); + + assertTrue(Duration.ofNanos(System.nanoTime() - start).toSeconds() < 20, + "the read must give up at the deadline, not run to the 4 MiB cap"); + } + } + + @Test + void a_stalled_peer_cannot_hold_the_calling_thread_either() { + // Nothing arrives at all, so an in-loop deadline check never runs — the + // watchdog has to close the stream out from under the blocked read. + try (AdcpHttpClient client = AdcpHttpClient.builder() + .ssrfPolicy(SsrfPolicy.permissive()) + .readTimeout(Duration.ofMillis(600)) + .build()) { + URI uri = URI.create(base + "/stall"); + long start = System.nanoTime(); + + assertThrows(IOException.class, () -> client.getForAgentCard(uri, Map.of())); + + assertTrue(Duration.ofNanos(System.nanoTime() - start).toSeconds() < 20, + "a stalled body read must not block forever"); + } + } + + @Test + void agent_card_cap_can_be_overridden_per_fetch() throws Exception { + // An agent configured for a bigger cap must get it on discovery too, + // not just on the call — otherwise its connection dies on the card. + try (AdcpHttpClient client = client()) { + URI uri = URI.create(base + "/declared?bytes=" + (32 * 1024)); + + assertThrows(ResponseTooLargeError.class, + () -> client.getForAgentCard(uri, Map.of())); + + AdcpHttpResponse response = client.getForAgentCard(uri, Map.of(), 64 * 1024); + assertEquals(32 * 1024, response.body().length); + } + } + + @Test + void agent_card_fetch_rejects_a_non_positive_cap() { + try (AdcpHttpClient client = client()) { + assertThrows(IllegalArgumentException.class, + () -> client.getForAgentCard(URI.create(base + "/chunked?bytes=1"), + Map.of(), 0)); + } + } + @Test void probe_and_call_caps_have_independent_defaults() { try (AdcpHttpClient client = AdcpHttpClient.builder().build()) { diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClientTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClientTest.java index 91127f9..9db901a 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClientTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClientTest.java @@ -24,6 +24,7 @@ import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -78,13 +79,18 @@ void startServer() throws IOException { long size = longParam(exchange, "size", 16); exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); exchange.sendResponseHeaders(200, 0); + String eol = switch (stringParam(exchange, "eol", "lf")) { + case "cr" -> "\r"; + case "crlf" -> "\r\n"; + default -> "\n"; + }; byte[] filler = new byte[(int) size]; Arrays.fill(filler, (byte) 'x'); try (OutputStream os = exchange.getResponseBody()) { for (long i = 0; i < events; i++) { os.write("data: ".getBytes(StandardCharsets.UTF_8)); os.write(filler); - os.write("\n\n".getBytes(StandardCharsets.UTF_8)); + os.write((eol + eol).getBytes(StandardCharsets.UTF_8)); os.flush(); } } catch (IOException expectedWhenCapAborts) { @@ -247,6 +253,54 @@ void sse_content_type_does_not_bypass_the_cap_on_a_call() { () -> post(client, "/sse?events=1&size=" + (CAP * 4L))); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"lf", "cr", "crlf"}) + void event_boundaries_reset_the_per_event_counter_for_every_sse_line_terminator(String eol) + throws Exception { + // SSE accepts CR, LF and CRLF as line terminators, and the JDK's + // fromLineSubscriber — which both transports sit behind — splits on all + // three. Missing one makes the per-event counter run on forever and + // kills a conforming peer's stream with a bogus ResponseTooLargeError. + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + HttpResponse response = + get(client, "/sse?events=8&size=" + (CAP / 4) + "&eol=" + eol); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().length() > CAP, + "8 events of a quarter-cap each is twice the cap in total"); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"lf", "cr", "crlf"}) + void a_single_over_cap_event_is_caught_for_every_line_terminator(String eol) { + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); + + IOException thrown = assertThrows(IOException.class, + () -> get(client, "/sse?events=1&size=" + (CAP * 4L) + "&eol=" + eol)); + + assertNotNull(ResponseTooLargeError.findIn(thrown)); + } + + @Test + void long_lived_get_sse_stream_still_has_a_finite_cumulative_ceiling() { + // The listening-stream exemption is triggered by the peer's own + // Content-Type, so it must not mean "unbounded": a peer answering a GET + // with text/event-stream and a forever-stream of small events still + // gets torn down. + long tinyCap = 2048; + HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, tinyCap); + long ceiling = tinyCap * ResponseSizeLimitingHttpClient.LISTENING_STREAM_MULTIPLIER; + long events = (ceiling / 512) + 16; + + IOException thrown = assertThrows(IOException.class, + () -> get(client, "/sse?events=" + events + "&size=500")); + + ResponseTooLargeError tooLarge = ResponseTooLargeError.findIn(thrown); + assertNotNull(tooLarge, "a listening stream must still have a finite ceiling"); + assertEquals(ceiling, tooLarge.limit()); + } + @Test void long_lived_get_sse_stream_is_capped_per_event_not_cumulatively() throws Exception { HttpClient client = ResponseSizeLimitingHttpClient.wrap(raw, CAP); @@ -351,6 +405,19 @@ void adcp_http_client_rejects_a_non_positive_transport_cap() { } } + @Test + void error_message_redacts_credentials_in_the_url() { + URI withSecrets = URI.create("https://svc:s3cr3t@agent.example.com/mcp?api_key=abc123"); + ResponseTooLargeError error = new ResponseTooLargeError(10, 20, withSecrets); + + assertFalse(error.getMessage().contains("s3cr3t"), + "userinfo must not reach a message that gets logged and shown to users"); + assertFalse(error.getMessage().contains("abc123"), + "query strings carry API keys"); + assertTrue(error.getMessage().contains("agent.example.com/mcp")); + assertEquals(withSecrets, error.url(), "url() still returns what was configured"); + } + @Test void find_in_walks_cause_and_suppressed_chains_without_looping() { ResponseTooLargeError error = new ResponseTooLargeError(1, 2, null); @@ -402,6 +469,20 @@ private static void writeBytes(HttpExchange exchange, long bytes) throws IOExcep } } + private static String stringParam(HttpExchange exchange, String name, String fallback) { + String query = exchange.getRequestURI().getQuery(); + if (query == null) { + return fallback; + } + for (String pair : query.split("&")) { + String[] kv = pair.split("=", 2); + if (kv.length == 2 && kv[0].equals(name)) { + return kv[1]; + } + } + return fallback; + } + private static long longParam(HttpExchange exchange, String name, long fallback) { String query = exchange.getRequestURI().getQuery(); if (query == null) { diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManagerTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManagerTest.java index 78f1a46..ffcfe25 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManagerTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/A2aConnectionManagerTest.java @@ -37,7 +37,7 @@ void getOrConnect_reuses_cached_client_for_same_url() { AtomicInteger loaderCalls = new AtomicInteger(); AtomicInteger factoryCalls = new AtomicInteger(); manager = new A2aConnectionManager( - (agent, headers) -> { + (agent, headers, cap) -> { loaderCalls.incrementAndGet(); return testCard(agent.agentUri()); }, @@ -65,7 +65,7 @@ void getOrConnect_different_auth_headers_get_separate_clients() { AtomicInteger factoryCalls = new AtomicInteger(); List> discoveryHeaders = new ArrayList<>(); manager = new A2aConnectionManager( - (agent, headers) -> { + (agent, headers, cap) -> { discoveryHeaders.add(Map.copyOf(headers)); return testCard(agent.agentUri()); }, @@ -94,7 +94,7 @@ void getOrConnect_different_auth_headers_get_separate_clients() { void buildCacheKey_does_not_include_authorization_header() { List> discoveryHeaders = new ArrayList<>(); manager = new A2aConnectionManager( - (agent, headers) -> { + (agent, headers, cap) -> { discoveryHeaders.add(Map.copyOf(headers)); return testCard(agent.agentUri()); }, @@ -125,7 +125,7 @@ void buildCacheKey_does_not_include_authorization_header() { void evict_exact_cache_hash_removes_only_matching_variant() { AtomicInteger factoryCalls = new AtomicInteger(); manager = new A2aConnectionManager( - (agent, headers) -> testCard(agent.agentUri()), + (agent, headers, cap) -> testCard(agent.agentUri()), (agentCard, maxResponseBytes) -> { factoryCalls.incrementAndGet(); return testClient(agentCard); @@ -154,7 +154,7 @@ void evict_exact_cache_hash_removes_only_matching_variant() { void evict_forces_reconnect() { AtomicInteger factoryCalls = new AtomicInteger(); manager = new A2aConnectionManager( - (agent, headers) -> testCard(agent.agentUri()), + (agent, headers, cap) -> testCard(agent.agentUri()), (agentCard, maxResponseBytes) -> { factoryCalls.incrementAndGet(); return testClient(agentCard); @@ -177,7 +177,7 @@ void evict_forces_reconnect() { @Test void getOrConnect_after_close_throws() { manager = new A2aConnectionManager( - (agent, headers) -> testCard(agent.agentUri()), + (agent, headers, cap) -> testCard(agent.agentUri()), (card, maxResponseBytes) -> testClient(card)); manager.close(); @@ -196,7 +196,7 @@ void httpAgentCardLoader_uses_agent_card_json_well_known_path() { List fetchedUris = new ArrayList<>(); // Use the package-private AgentCardLoader interface to capture the URI manager = new A2aConnectionManager( - (agent, headers) -> { + (agent, headers, cap) -> { // Reconstruct what HttpAgentCardLoader would build URI expected = URI.create(agent.agentUri().getScheme() + "://" + agent.agentUri().getAuthority() + "/.well-known/agent-card.json"); @@ -230,7 +230,7 @@ void httpAgentCardLoader_buildAgentCardUri_uses_standard_path() { void authenticated_card_discovery_passes_auth_headers_to_loader() { List> capturedHeaders = new ArrayList<>(); manager = new A2aConnectionManager( - (agent, headers) -> { + (agent, headers, cap) -> { capturedHeaders.add(Map.copyOf(headers)); return testCard(agent.agentUri()); }, diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/ResponseSizeCapWiringTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/ResponseSizeCapWiringTest.java index d078115..7d48427 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/ResponseSizeCapWiringTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/transport/a2a/ResponseSizeCapWiringTest.java @@ -78,7 +78,7 @@ void call_options_override_the_agent_setting() { void connections_are_cached_per_cap() { RecordingFactory factory = new RecordingFactory(); try (A2aConnectionManager manager = new A2aConnectionManager( - (cfg, headers) -> card(cfg.agentUri()), factory)) { + (cfg, headers, cap) -> card(cfg.agentUri()), factory)) { AgentConfig agent = agent(null); Client first = manager.getOrConnect(agent, Map.of(), "hash", 1_000L); @@ -96,7 +96,7 @@ void connections_are_cached_per_cap() { void evict_removes_every_cap_variant() { RecordingFactory factory = new RecordingFactory(); try (A2aConnectionManager manager = new A2aConnectionManager( - (cfg, headers) -> card(cfg.agentUri()), factory)) { + (cfg, headers, cap) -> card(cfg.agentUri()), factory)) { AgentConfig agent = agent(null); manager.getOrConnect(agent, Map.of(), "hash", 1_000L); @@ -109,11 +109,51 @@ void evict_removes_every_cap_variant() { } } + @Test + void transport_http_clients_are_released_when_their_connection_is_evicted() { + // Neither JdkA2AHttpClient nor Client.close() releases the JDK HTTP + // client underneath, and each one owns a selector thread. A caller that + // varies maxResponseBytes per call would otherwise accumulate them + // without bound. + AdcpHttpClient http = AdcpHttpClient.builder().build(); + A2aConnectionManager.DefaultClientFactory factory = + new A2aConnectionManager.DefaultClientFactory(http); + try (http; A2aConnectionManager manager = new A2aConnectionManager( + (cfg, headers, cap) -> card(cfg.agentUri()), factory)) { + + AgentConfig agent = agent(null); + for (long cap = 1_000; cap <= 20_000; cap += 1_000) { + manager.getOrConnect(agent, Map.of(), "hash", cap); + } + assertTrue(factory.pooledClientCount() > 0); + + manager.evict(agent.agentUri(), "hash"); + assertEquals(0, factory.pooledClientCount(), + "every cap variant's HTTP client should be released on eviction"); + } + } + + @Test + void transport_http_clients_are_shared_while_the_cap_is_shared() { + AdcpHttpClient http = AdcpHttpClient.builder().build(); + A2aConnectionManager.DefaultClientFactory factory = + new A2aConnectionManager.DefaultClientFactory(http); + try (http; A2aConnectionManager manager = new A2aConnectionManager( + (cfg, headers, cap) -> card(cfg.agentUri()), factory)) { + + // Five agents, all on the default cap: one HTTP client between them. + for (int i = 0; i < 5; i++) { + manager.getOrConnect(agent(null), Map.of(), "hash-" + i, null); + } + assertEquals(1, factory.pooledClientCount()); + } + } + @Test void a_non_positive_override_is_rejected() { RecordingFactory factory = new RecordingFactory(); try (A2aConnectionManager manager = new A2aConnectionManager( - (cfg, headers) -> card(cfg.agentUri()), factory)) { + (cfg, headers, cap) -> card(cfg.agentUri()), factory)) { AgentConfig agent = agent(null); org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> manager.getOrConnect(agent, Map.of(), "hash", 0L)); @@ -130,7 +170,7 @@ void a_non_positive_override_is_rejected() { private static List capsSeenBy(AgentConfig agent, CallToolOptions options) { RecordingFactory factory = new RecordingFactory(); A2aConnectionManager a2a = new A2aConnectionManager( - (cfg, headers) -> card(cfg.agentUri()), factory); + (cfg, headers, cap) -> card(cfg.agentUri()), factory); try (ProtocolClient client = new ProtocolClient( AdcpObjectMapperFactory.create(), SsrfPolicy.permissive(), null, new McpConnectionManager(Duration.ofSeconds(1)), a2a)) { diff --git a/docs/response-size-limits.md b/docs/response-size-limits.md new file mode 100644 index 0000000..ef11239 --- /dev/null +++ b/docs/response-size-limits.md @@ -0,0 +1,106 @@ +# Response size limits + +Every response an AdCP agent sends you is capped. The default is **4 MiB**, it is on without any +configuration, and it is enforced while the body streams in — an oversized response is aborted +mid-read rather than buffered and then rejected. + +Agent responses are untrusted input. Without the cap, one agent answering `get_products` with a +17.8 MB payload is enough to push a grader past 10 GB of memory and take down everything else +running in the same process. See [`specs/response-size-limits.md`](../specs/response-size-limits.md) +for the design, and [adcp#8190](https://github.com/adcontextprotocol/adcp/issues/8190) for the +cross-SDK contract. + +## What is capped + +MCP `callTool`, `tools/list` and discovery; A2A `message/send`; SSE streams on both; agent-card +fetches. Discovery probes — transport detection and `WWW-Authenticate` challenges, which read only +the status line and headers — keep a separate, smaller 4 KiB budget. + +## Raising the limit + +There is no opt-out. Every setter rejects a non-positive value: an agent that legitimately publishes +a very large catalogue gets a bigger number, not an uncapped read. + +```java +// Per client — applies to every agent this client talks to. +AdcpClient client = AdcpClient.builder() + .agent(agent) + .maxResponseBytes(32L * 1024 * 1024) + .build(); + +// Per agent — for the one seller with the enormous catalogue. +AgentConfig agent = AgentConfig.builder() + .id("big-catalogue-seller") + .agentUri(URI.create("https://agent.example.com")) + .maxResponseBytes(32L * 1024 * 1024) + .build(); + +// Per call — for the one call that needs it. +client.callTool("get_products", args, GetProductsResponse.class, + CallToolOptions.builder().maxResponseBytes(32L * 1024 * 1024).build()); +``` + +Resolution order is **call → agent → client**. The cap is baked into the transport connection, so +overriding it per call opens a separate pooled connection; prefer setting it on the `AgentConfig` +if an agent routinely needs more. + +## Handling the error + +An over-cap response throws `ResponseTooLargeError` (`code() == "RESPONSE_TOO_LARGE"`), carrying +`limit()`, `bytesRead()` and `url()`. Nothing is truncated — you never get back a half-read payload. + +The cap fires inside a JDK HTTP body subscriber, so by the time it reaches you the JDK or the +MCP/A2A transport may have wrapped it. Branch on it with `findIn`: + +```java +try { + return client.callTool("get_products", args, GetProductsResponse.class); +} catch (RuntimeException e) { + ResponseTooLargeError tooLarge = ResponseTooLargeError.findIn(e); + if (tooLarge != null) { + log.warn("{} answered with more than {} bytes — skipping", + agent.id(), tooLarge.limit()); + return null; + } + throw e; +} +``` + +`ResponseTooLargeError` is not retried by the SDK's evict-and-retry path: re-running the call would +just re-download the same oversized payload. + +## Upgrading + +This is a behaviour change. Before, the only limit was a 10 MB character check that ran *after* the +response had been fully buffered and parsed; now the effective limit is 4 MiB and it stops the read. +Two things to look at when you upgrade: + +- **Large catalogues.** If a call that used to return 6 MB now throws, raise the cap for that agent. + Start with the per-agent setter — it keeps the tighter default for everyone else. +- **Agent cards over 4 KiB.** Card fetches used to run on the 4 KiB probe budget, and a larger card + was silently truncated and replaced with a synthetic fallback. They now run on the 4 MiB call cap, + so a card with a realistic number of skills is read whole for the first time. If you were relying + on the fallback card's shape, you will now see the agent's real card. + +## SSE + +Streaming responses get no extra budget. Each SSE event is capped individually, and for anything +that answers a single call the running total across events is capped too — so a peer cannot evade +the limit by replying with `Content-Type: text/event-stream` or by drip-feeding small events. + +Note that on a capped call, progress and status events count against the same budget as the final +payload — a long-running task that emits many status updates can reach the limit without any single +message being large. + +The one exception is the long-lived MCP SSE session channel: a `GET` that stays open for the life of +the connection and carries every server-to-client message. Capping its running total at one call's +worth would kill a healthy long session after enough legitimate traffic, so it gets a cumulative +budget 256× larger, with the per-event cap unchanged. Per-event is the cap that bounds memory there +— the transport aggregates one event at a time and discards the bytes in between. + +## What the cap does not do + +It bounds what one peer can make you hold for one response. It is not a global memory budget: wire +bytes become a multiple of themselves once parsed into objects, and a batch job talking to many +agents multiplies that again. If you are fanning out — the shape that got OOM-killed in the first +place — bound your concurrency as well. diff --git a/specs/response-size-limits.md b/specs/response-size-limits.md index 2e38b23..4555424 100644 --- a/specs/response-size-limits.md +++ b/specs/response-size-limits.md @@ -4,6 +4,7 @@ **Tracks:** [`transport`](../ROADMAP.md#track-3--l0-transport-mcp--a2a) **Decisions referenced:** D2 (JDK 21), D9 (MCP SDK pin), D10 (A2A SDK pin), D24 **Cross-SDK tracking:** [adcontextprotocol/adcp#8190](https://github.com/adcontextprotocol/adcp/issues/8190) +**Adopter guide:** [`docs/response-size-limits.md`](../docs/response-size-limits.md) ## Why this exists @@ -82,25 +83,43 @@ decorates a JDK `HttpClient`. `AdcpHttpClient.newHttpClientBuilder(long)` return cancelled the instant the cap is crossed. A check after the body is buffered or parsed would not prevent the OOM this exists to stop. 3. **SSE per event and per call.** For `Content-Type: text/event-stream`, bytes are counted per - event (reset at each blank-line delimiter) *and* cumulatively for the whole response when the - response answers a single call — any non-`GET` request. Answering a POST with - `text/event-stream` buys a peer no extra budget. + event (reset at each blank-line delimiter, accepting CR, LF and CRLF framing — all three are + legal SSE terminators and all three are what the JDK's `fromLineSubscriber` splits on) *and* + cumulatively for the whole response. Answering a POST with `text/event-stream` buys a peer no + extra budget, and neither does drip-feeding small events. 4. **Decompressed bytes.** Outbound requests are rewritten to `Accept-Encoding: identity`, replacing any caller-supplied value, so a peer cannot hide a 1 GiB payload behind a small gzip stream. (The JDK `HttpClient` never decompresses transparently, so wire bytes *are* the bytes the caller holds.) +5. **A bounded read in time, not just in bytes.** `HttpRequest.timeout` covers only the status line + and headers — the JDK fires its timer on the response stage, not the body. So `getForAgentCard` + gives the body read its own deadline: a watchdog closes the stream at `readTimeout`, which + unblocks a stalled `read()`, and the read loop checks the same deadline so a drip-feed gets a + clear `HttpTimeoutException`. Without it, moving card fetches from the 4 KiB probe cap to the + 4 MiB call cap would have widened the slow-loris window by a factor of a thousand. ### The one deliberate asymmetry: long-lived `GET` SSE streams The MCP SSE session channel is a `GET` that stays open for the life of the connection and carries -every server-to-client message. A cumulative cap there would kill healthy long sessions after -enough legitimate traffic — so that stream gets the **per-event cap only**. - -This is the right trade for the threat being defended against. OOM comes from how much is held at -once, and the transports aggregate one SSE event at a time, discarding bytes in between: per-event -is the cap that bounds memory. Cumulative capping is about bounding a *single call*, and a listening -stream is not a call. Every path that *is* a call — MCP StreamableHTTP POST, A2A -`message/send`, A2A streaming POST — is capped cumulatively as well as per event. +every server-to-client message. Holding its running total to a single call's cap would kill healthy +long sessions after enough legitimate traffic — so a `GET` answered with `text/event-stream` gets a +cumulative budget of `maxResponseBytes × 256` instead, with its per-event cap unchanged. + +Per-event is the cap that matters for memory here. OOM comes from how much is held at once, and the +transports aggregate one SSE event at a time, discarding bytes in between. Cumulative capping is +about bounding a *single call*, and a listening stream is not a call. Every path that *is* a call — +MCP StreamableHTTP POST, A2A `message/send`, A2A streaming POST — is capped cumulatively at +`maxResponseBytes` as well as per event. + +The multiplier is finite on purpose. Which branch a response takes is decided partly by the +*peer's* `Content-Type`, so "no cumulative cap at all" would be a decision an attacker gets to make: +answer any `GET` with `text/event-stream` and a forever-stream of 1 KB events, and the stream never +ends. With the multiplier, the worst case is a long stream that eventually terminates. + +One consequence worth knowing when debugging: on a call that *is* capped cumulatively, progress and +status events count against the same budget as the final payload. A long-running `create_media_buy` +that emits many status updates can reach `RESPONSE_TOO_LARGE` without any single message being +large. ### Probes keep their own, smaller cap @@ -113,15 +132,29 @@ flagging the result via `AdcpHttpResponse.truncated()` — not silent, and no ca on the 4 KiB probe cap and an agent card larger than 4 KiB — easily reached with a handful of skills — was silently truncated and swapped for a synthetic fallback card. +## What the cap does not bound + +The cap is **per in-flight response**. It bounds what one peer can make this process hold for one +call — it is not a global memory budget. Two things follow: + +- Wire bytes are not resident bytes. A 4 MiB body is buffered, copied out of the growing buffer, and + then parsed into an object graph; peak RSS for one response is a multiple of the cap. +- A caller fanning out across many agents multiplies that by its concurrency. The incident this cap + exists to prevent was a *batch* grader. Bound your concurrency too. + ## Connection caching The cap is baked into the transport's `HttpClient` at connect time, so it is part of the connection cache key: `agentUrl::tokenHash::maxResponseBytes` (MCP) and `agentUri#cacheHash#maxResponseBytes` (A2A). A call that overrides the cap gets a connection built for that cap rather than silently -reusing one built for another. Varying the cap per call therefore fragments the connection cache — -prefer setting it once on the `AgentConfig`. `A2aConnectionManager` pools at most 8 distinct capped -HTTP clients and builds unpooled ones beyond that, so a caller that varies the cap wildly cannot -grow an unbounded pool. +reusing one built for another, and the override reaches agent-card discovery as well as the call +itself. Varying the cap per call therefore fragments the connection cache — prefer setting it once +on the `AgentConfig`. + +`A2aConnectionManager` keeps one JDK `HttpClient` per distinct cap, reference-counted by the A2A +clients using it. Neither `JdkA2AHttpClient` nor `Client.close()` releases that HTTP client, and +each one owns a selector thread, so the factory tracks ownership itself: clients on the same cap +share one (the common case — every agent on the default), and the last one evicted shuts it down. ## Cross-SDK contract From cac8d754c6e51a90723b506d6e4e0e9c726f56f4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 14 Oct 2026 02:51:26 +0000 Subject: [PATCH 3/3] docs(transport): correct a stale comment on the listening-stream cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../adcp/http/ResponseSizeLimitingHttpClient.java | 14 +++++++++++--- specs/response-size-limits.md | 4 ++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java index 60e98f3..86637af 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/http/ResponseSizeLimitingHttpClient.java @@ -290,15 +290,18 @@ private HttpResponse.BodyHandler limiting( HttpRequest request, HttpResponse.BodyHandler delegateHandler) { Objects.requireNonNull(delegateHandler, "responseBodyHandler"); // A GET that comes back as SSE is a long-lived listening stream, not the - // response to a single call: cap each event but not the running total. + // response to a single call, so its running total gets a larger ceiling. boolean listeningStream = "GET".equalsIgnoreCase(request.method()); URI uri = request.uri(); long cap = maxResponseBytes; return responseInfo -> { + // 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. long declared = declaredContentLength(responseInfo); if (declared > cap) { log.debug("Rejecting response from {}: Content-Length {} exceeds cap {}", - uri, declared, cap); + redact(uri), declared, cap); return new FailedBodySubscriber<>( new ResponseTooLargeError(cap, declared, uri)); } @@ -321,6 +324,11 @@ private HttpResponse.BodyHandler limiting( handler -> acceptor.apply(limiting(pushPromiseRequest, handler))); } + /** Keeps credentials in a URI out of log lines. */ + private static Object redact(@Nullable URI uri) { + return uri == null ? "(unknown)" : AdcpHttpClient.redact(uri); + } + private static long saturatingMultiply(long value, int factor) { try { return Math.multiplyExact(value, (long) factor); @@ -538,7 +546,7 @@ private void abort(long limit, long bytesRead) { return; } ResponseTooLargeError error = new ResponseTooLargeError(limit, bytesRead, uri); - log.debug("Aborting response from {}: {}", uri, error.getMessage()); + log.debug("Aborting response from {}: {}", redact(uri), error.getMessage()); // Complete our own future first so the delegate's terminal signal // cannot overwrite the typed error. body.completeExceptionally(error); diff --git a/specs/response-size-limits.md b/specs/response-size-limits.md index 4555424..094188d 100644 --- a/specs/response-size-limits.md +++ b/specs/response-size-limits.md @@ -111,6 +111,10 @@ about bounding a *single call*, and a listening stream is not a call. Every path MCP StreamableHTTP POST, A2A `message/send`, A2A streaming POST — is capped cumulatively at `maxResponseBytes` as well as per event. +A response that declares a `Content-Length` is held to the per-call cap regardless of its +content type: a body of known, finite size is a payload, not a listening stream, so it gets no +share of the larger ceiling. + The multiplier is finite on purpose. Which branch a response takes is decided partly by the *peer's* `Content-Type`, so "no cumulative cap at all" would be a decision an attacker gets to make: answer any `GET` with `text/event-stream` and a forever-stream of 1 KB events, and the stream never