modelcontextprotocol / modelcontextprotocol/java-sdk

Allow composing request customizers on the HTTP client transport builders

Đang mở
#1,073 2 bình luận 1 reaction 1 người được giao Xem trên GitHub

@Kehrlann đang làm issue này rồi.

Từ ngày 7/8/2026.

area/client area/transport P2
Ngôn ngữ chính
Java
Star
3.7k
Fork
1.1k
Merge trung bình
1 ngày 15 giờ
Pull request đã merge (30 ngày)
9

Mô tả

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.

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.