Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/cap-agent-response-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"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.
- 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 `docs/response-size-limits.md`, with the
design in `specs/response-size-limits.md`.
Cross-SDK contract: https://github.com/adcontextprotocol/adcp/issues/8190
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), 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)

Expand Down Expand Up @@ -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) |
Expand Down
39 changes: 39 additions & 0 deletions adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@
* <p>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)}.
*
* <p><b>Response size cap.</b> 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 {

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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() {}

Expand Down Expand Up @@ -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}).
*
* <p>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.
*
* <p>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);
Expand Down
45 changes: 42 additions & 3 deletions adcp/src/main/java/org/adcontextprotocol/adcp/AgentConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
* <li>OAuth auth-code ({@code oauthTokens})</li>
* </ul>
*
* <p>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(
Expand All @@ -39,7 +43,8 @@ public record AgentConfig(
@Nullable String webhookUrlTemplate,
@Nullable String webhookSecret,
@Nullable AdcpVersion adcpVersion,
Map<String, String> extraHeaders
Map<String, String> extraHeaders,
@Nullable Long maxResponseBytes
) {

private static final Logger log = LoggerFactory.getLogger(AgentConfig.class);
Expand All @@ -52,6 +57,7 @@ public record AgentConfig(
validateAuth(authToken, basicAuth, oauthClientCredentials, oauthTokens);
validateAuthToken(authToken);
validateExtraHeaders(extraHeaders);
validateMaxResponseBytes(maxResponseBytes);
warnPlaintextAuth(agentUri, authToken, basicAuth, oauthClientCredentials, oauthTokens);
}

Expand All @@ -68,7 +74,8 @@ public String toString() {
+ ", webhookSecret=" + (webhookSecret != null ? "<REDACTED>" : "null")
+ ", adcpVersion=" + adcpVersion
+ ", extraHeaders=" + (extraHeaders.isEmpty()
? "{}" : "<" + extraHeaders.size() + " headers>") + "]";
? "{}" : "<" + extraHeaders.size() + " headers>")
+ ", maxResponseBytes=" + maxResponseBytes + "]";
}

/** Creates a builder for {@code AgentConfig}. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -166,6 +181,7 @@ public static final class Builder {
private @Nullable String webhookSecret;
private @Nullable AdcpVersion adcpVersion;
private Map<String, String> extraHeaders = Map.of();
private @Nullable Long maxResponseBytes;

private Builder() {}

Expand Down Expand Up @@ -246,6 +262,29 @@ public Builder extraHeaders(Map<String, String> 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}).
*
* <p>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) {
Expand All @@ -258,7 +297,7 @@ public AgentConfig build() {
id, agentUri, protocol,
authToken, basicAuth, oauthClientCredentials, oauthTokens,
webhookUrlTemplate, webhookSecret, adcpVersion,
extraHeaders);
extraHeaders, maxResponseBytes);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,114 @@
import org.jspecify.annotations.Nullable;

import java.net.URI;
import java.net.URISyntaxException;

/** Response body exceeded the configured maximum size. */
/**
* Response body exceeded the configured maximum size.
*
* <p>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}.
*
* <p>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;

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.
*
* <p>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.
*
* <p>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 — as configured,
* including any userinfo or query. {@link #getMessage()} carries a redacted
* form instead.
*/
public @Nullable URI url() {
return url;
}
Expand Down
Loading
Loading