Skip to content

Allow composing request customizers on the HTTP client transport builders #1073

Description

@iuliiasobolevska

Expected Behavior

A caller should be able to add a request customizer to a transport builder without discarding the ones already registered on it.

Two shapes would work. The smaller one is a getter, so a caller can compose by hand:

var existing = builder.getAsyncHttpRequestCustomizer();
builder.asyncHttpRequestCustomizer(new DelegatingMcpAsyncHttpClientRequestCustomizer(List.of(existing, mine)));

The better one is an additive setter alongside the existing replace-all one:

public Builder addAsyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer customizer) {
    Assert.notNull(customizer, "customizer must not be null");
    this.httpRequestCustomizers.add(customizer);
    return this;
}

with build() collapsing the list through DelegatingMcpAsyncHttpClientRequestCustomizer, plus the sync twin for McpSyncHttpClientRequestCustomizer.

Either would apply to both HttpClientStreamableHttpTransport.Builder and HttpClientSseClientTransport.Builder.

Making the existing setter additive would be the cleanest API, but it would change behavior for anyone who calls it twice today and expects a replacement, so it probably belongs in a major version.

Current Behavior

Both builders hold exactly one customizer, and the setter assigns it. On main at fd00498:

// HttpClientStreamableHttpTransport
private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer;                                        // 129
private McpAsyncHttpClientRequestCustomizer httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.NOOP;   // 721
this.httpRequestCustomizer = asyncHttpRequestCustomizer;                                                        // 852
// HttpClientSseClientTransport
private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer;                                        // 124
private McpAsyncHttpClientRequestCustomizer httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.NOOP;   // 192
this.httpRequestCustomizer = asyncHttpRequestCustomizer;                                                        // 301

The sync overload routes through McpAsyncHttpClientRequestCustomizer.fromSync(...) into the same field, so a sync customizer and an async one overwrite each other as well.

There is no add... variant and no getter. You can't install a customizer without discarding whatever was there before, and you can't find out that you did.

There's no way to work around it outside the SDK either. The field is private, the builder is the only path to it, and by the time you hold a built transport, the customizer has already been captured.

DelegatingMcpAsyncHttpClientRequestCustomizer and DelegatingMcpSyncHttpClientRequestCustomizer already exist in io.modelcontextprotocol.client.transport.customizer and do exactly the chaining the additive setter needs. The builders just don't use them.

Context

For an application customizing its own transport, one slot is enough. It stops being enough once more than one party wants a header on outbound MCP requests: an auth integration attaching a credential, a tracing library adding a correlation ID, the application adding something of its own. They all target the same setter, and the last call replaces the rest with no error and nothing logged.

A library in that position can't guarantee its header is present. The symptom is a missing header at runtime on a request that otherwise looks fine, rather than anything at startup.

Alternatives considered:

  • Install a chain of our own and document "please don't call asyncHttpRequestCustomizer directly". That's a convention, not a contract, and it breaks silently.
  • Collect every participant before build() and set the composed customizer once. This works, but only the code that owns the builder can do it. In a Spring Boot application, that's the autoconfiguration, so the problem moves a layer up instead of getting solved, and the composition logic has to be rebuilt by every framework that wraps the SDK. This is what we do today.
  • Wrap the built transport. Not viable, the customizer is consumed inside the transport's own request paths.

The workaround holds, but it puts the responsibility in the wrong place. A getter on its own would unblock callers immediately without changing any existing behavior.

Activity

  1. added this to the 3.x Planning milestone on Aug 7, 2026
  2. self-assigned this
    on Aug 7, 2026
  3. Kehrlann commented on Aug 7, 2026

    @Kehrlann
    Contributor

    Thanks for the report. Let's target the next minor for this.

    If we have a .add... method, we should also consider a Builder asyncHttpRequestCustomizers(Consumer<List<McpAsyncHttpClientRequestCustomizer>> customizersConsumer) so that the builder can be further customized beyond defaults a library adds.

  4. added
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Aug 7, 2026
  5. iuliiasobolevska commented on Aug 8, 2026

    @iuliiasobolevska
    ContributorAuthor

    Thank you for taking a look and for the response!

    The Consumer<List<...>> variant covers a case that an append-only add... would not, so I'd treat it as the primary ask:

    One of the customizers we install has to run first. It exists to keep a credential off the outbound request, and since HttpRequest.Builder can neither read nor remove a header, the only way to do that is to pass the rest of the chain a wrapping builder that drops the header as it is being set. That only reaches customizers positioned after it. We do this in a library, so we need to control position, not just membership.

  6. added 2 commits that reference this issue on Oct 8, 2026
    e5460c9
    8590a8d
  7. added a commit that references this issue on Oct 8, 2026
    90db9a6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Moderate issues affecting some users, edge cases, potentially valuable featurearea/clientarea/transport

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions