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.
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:
The better one is an additive setter alongside the existing replace-all one:
with
build()collapsing the list throughDelegatingMcpAsyncHttpClientRequestCustomizer, plus the sync twin forMcpSyncHttpClientRequestCustomizer.Either would apply to both
HttpClientStreamableHttpTransport.BuilderandHttpClientSseClientTransport.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
mainatfd00498: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.
DelegatingMcpAsyncHttpClientRequestCustomizerandDelegatingMcpSyncHttpClientRequestCustomizeralready exist inio.modelcontextprotocol.client.transport.customizerand 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:
asyncHttpRequestCustomizerdirectly". That's a convention, not a contract, and it breaks silently.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.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.