Skip to content

Headers on the remote client transports: webSocketCustomizer and requestCustomizer - #17

Open
cepage wants to merge 1 commit into
agentclientprotocol:mainfrom
cepage:client-transport-headers
Open

cepage wants to merge 1 commit into
agentclientprotocol:mainfrom
cepage:client-transport-headers

Conversation

@cepage

@cepage cepage commented Oct 4, 2026

Copy link
Copy Markdown

Why

The JDK's HttpClient has no default headers, so neither WebSocketAcpClientTransport nor StreamableHttpAcpClientTransport can send an Authorization header, an API key, or any other header an agent's endpoint requires. Today an application that talks to an authenticated agent has to write its own transport. goose serve is one such agent: it refuses every connection without X-Secret-Key (the alternative is --dangerously-unauthenticated).

What

var ws = new WebSocketAcpClientTransport(URI.create("wss://agents.example.com/acp"))
    .webSocketCustomizer(builder -> builder.header("Authorization", "Bearer " + token));

var http = new StreamableHttpAcpClientTransport(URI.create("https://agents.example.com/acp"))
    .requestCustomizer(builder -> builder.header("Authorization", "Bearer " + tokens.current()));
  • webSocketCustomizer(Consumer<WebSocket.Builder>) runs on every connect attempt, after the connect timeout is set.
  • requestCustomizer(Consumer<HttpRequest.Builder>) runs for every request: the cleartext probe, initialize, each POST, every SSE stream opened or reopened, and the closing DELETE. A token that expires is therefore read again for each.
  • The HTTP transport keeps the protocol. The customizer runs on a builder of its own, and the result is copied with HttpRequest.newBuilder(request, filter): Content-Type, Accept, Acp-Connection-Id and Acp-Session-Id are filtered out and the endpoint's URI is restored. A copy is needed because a builder can replace a header but never remove one, and the bootstrap initialize has no connection id of the transport's own to replace a customizer's with. Other settings, such as a per-request timeout, are kept.
  • Failures stay in the Mono. Each HTTP request is now built inside its Mono, so a customizer that throws, or a header the JDK restricts, fails that request instead of escaping sendMessage, and a failed initialize may be sent again. The WebSocket connect behaves the same way and may be retried.
  • initialize is built after the cleartext probe rather than re-stamped with the settled version, so the package-private StreamableHttpRequests.pinned(HttpRequest) is gone.

The shape follows the customizeRequest hook of the MCP Java SDK's HTTP client transport.

Verification

  • ./mvnw clean verify: all 16 modules, 2,062 tests, 0 failures (JDK 21, Error Prone / NullAway profile).
  • New tests:
    • WebSocketAcpClientTransportTest: a real handshake against the JDK's HttpServer, which records the Authorization header and answers 401; a throwing customizer fails the connect, which can then be retried; a reserved Sec-WebSocket-* header fails the connect; null is rejected.
    • StreamableHttpAcpClientTransportTest: across POST, GET, POST and DELETE, every request carries the header once; a changed token reaches the next request; a customizer's Acp-Connection-Id and Accept are dropped, including on the bootstrap POST; a throwing customizer fails the Mono and initialize can be retried; null is rejected.
  • Against a real goose serve 1.52.0, both transports: without the header, the WebSocket handshake is refused and initialize over HTTP gets 401. With X-Secret-Key set through either customizer, initialize and session/new succeed.

CHANGELOG entry under [Unreleased] → Added.

🤖 Generated with Claude Code

…estCustomizer

The JDK's HttpClient has no default headers, so neither
WebSocketAcpClientTransport nor StreamableHttpAcpClientTransport could
send an Authorization header, an API key or any other header an agent's
endpoint requires; an application had to write its own transport.
goose serve, for one, refuses every connection without X-Secret-Key.

- WebSocketAcpClientTransport.webSocketCustomizer(Consumer<WebSocket.Builder>)
  runs on every connect attempt, after the connect timeout is set. A
  customizer that throws, or sets a header the JDK reserves for the
  handshake, fails that connect, which may be tried again.
- StreamableHttpAcpClientTransport.requestCustomizer(Consumer<HttpRequest.Builder>)
  runs for every request the transport sends: the cleartext probe,
  initialize, each POST, every SSE stream opened or reopened, and the
  closing DELETE, so a token that expires is read again for each. It
  runs on a builder of its own and the result is copied with the
  protocol headers (Content-Type, Accept, Acp-Connection-Id,
  Acp-Session-Id) filtered out and the endpoint's URI restored, because
  a builder can replace a header but not remove one: the bootstrap
  initialize carries no connection id of the transport's own to replace
  a customizer's with.
- Each HTTP request is now built inside its Mono, so a customizer that
  throws fails that Mono rather than escaping sendMessage, and a failed
  initialize may be sent again. initialize is built after the cleartext
  probe instead of being re-stamped with the settled version, so
  StreamableHttpRequests.pinned(HttpRequest) is gone.

Verified against goose serve 1.52.0: without the header the WebSocket
handshake is refused and initialize over HTTP answers 401; with
X-Secret-Key from either customizer, initialize and session/new succeed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant