Visitar URL original
Request customizers can't tell which connection a request belongs to · Issue #1074 · modelcontextprotocol/java-sdk · GitHub
Skip to content

Request customizers can't tell which connection a request belongs to #1074

Description

@iuliiasobolevska

Expected Behavior

A request customizer should be able to find out which configured connection the request it is customizing belongs to.

The cheaper option is a well-known key in the transport context, seeded by the transport, so no signature changes:

Publisher<HttpRequest.Builder> customize(HttpRequest.Builder builder, String method, URI endpoint,
        String body, McpTransportContext context) {
    String connection = (String) context.get(McpTransportContext.CONNECTION_NAME);
    builder.setHeader("Authorization", tokenFor(connection));
    return Mono.just(builder);
}

The transport already knows the name when it is built, so it would set it once in the builder and merge it into whatever context the caller supplied.

The other option is to put it in the signature:

Publisher<HttpRequest.Builder> customize(String connectionName, HttpRequest.Builder builder, String method,
        URI endpoint, String body, McpTransportContext context);

This is breaking, but it lines up with McpClientCustomizer#customize(String name, T spec) in Spring AI, which already takes the name first. If the interface is going to change shape at some point anyway, that's the more consistent end state.

Current Behavior

A request customizer runs for every outbound HTTP request on a transport, but isn't told which connection the request belongs to. On main at fd00498:

// McpAsyncHttpClientRequestCustomizer:27
Publisher<HttpRequest.Builder> customize(HttpRequest.Builder builder, String method, URI endpoint,
        @Nullable String body, McpTransportContext context);

The only discriminator available is the endpoint URI. There is no connection name in the context either: nothing in mcp-core defines or sets one, and the transport passes the caller's context through untouched. HttpClientStreamableHttpTransport reads it the same way on all four request paths, at lines 205, 291, 430, and 514:

var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", uri, jsonBody, transportContext));

So a customizer that needs per-connection behavior has to build its own URL-to-name map and reverse-match on the endpoint for every request. That map has to be assembled by re-reading the same configuration that the framework already parsed, and the match breaks as soon as a URL is templated, redirected, or differs by a trailing slash.

The name is known at the point the customizer is installed, since the transport is built per connection. It just isn't carried through to the call.

Context

Callers configure connections by name. In Spring AI, for example:

spring.ai.mcp.client.streamable-http.connections:
  first-server:
    url: https://...
  second-server:
    url: https://...

Anything per-connection needs that name: a different credential or audience per server, per-connection metrics, per-connection logging. We hit this with credentials, where each MCP server is a distinct audience, and the token must be minted for the correct one.

Alternatives considered:

  • One customizer instance per connection, closing over the name, installed by whoever builds the transport. This is what we do today. It works, but it only works for the party that owns the builder, and it interacts badly with the single-customizer-slot problem (Allow composing request customizers on the HTTP client transport builders #1073).
  • A URL to name map inside the customizer, reverse-matched on endpoint. Fragile for the reasons above, and it duplicates configuration parsing.
  • Putting the name into the caller's context at the call site. Requires every caller to know which connection a given client operation will reach, which they generally don't.

Is there a reason the connection name was deliberately omitted from the customizer contract? I might be missing something.

Activity

  1. Kehrlann commented on Aug 7, 2026

    @Kehrlann
    Contributor

    Thanks for the detailed writeup.

    You're right, as of now, we have adopted he pattern one customizer per client (see McpClientCustomizer). If we fix #1073 , would that be enough?

    Note that with the current implementation, you don't need a URL-to-name map. You can put the connection name (or any per-connection metadata) in the McpTransportContext. Depends on the client type

    1. Sync clients — transportContextProvider

    McpClient.sync(transport)
        .transportContextProvider(() -> McpTransportContext.create(
            Map.of("connectionName", "first-server")))
        .build();

    This supplier is invoked and written into the Reactor context before every blocking call, so your customizer sees it:

    String connection = (String) context.get("connectionName");

    2. Async clients — Reactor context directly

    There's no contextProvider hook for async; you write the context around the call yourself:

    client.callTool(request)
        .contextWrite(ctx -> ctx.put(McpTransportContext.KEY,
            McpTransportContext.create(Map.of("connectionName", "first-server"))))
        .subscribe();

    Usually it's propagated from the caller (e.g. Spring AI). If you'd like passing the context around explicitly, you could extend McpAsyncClient to add stuff inside the context before every call. It's clunky but works.

  2. self-assigned this
    on Aug 7, 2026
  3. added
    P3Nice to haves, rare edge cases
    on Aug 7, 2026
  4. iuliiasobolevska commented on Aug 8, 2026

    @iuliiasobolevska
    ContributorAuthor

    Thank you for taking a look!

    Agree on the transport context, and that's what we ship today. We put the connection name in under a constant and expose a reader for it so an application's own customizer can pull it back out. There's no URL-to-name map anywhere; I should have phrased it better in the issue.

    Fixing #1073 wouldn't be enough. These two issues are independent. #1073 is about how many customizers survive on a transport and in what order. It doesn't tell any of them which connection they're on.

    The one-customizer-per-client pattern does cover framework code: McpClientCustomizer is handed the connection name at build time, so we build one instance per connection and close over it. What's left is application code. Once spring-projects/spring-ai#6716 is released, the supported hook would be a bare McpAsyncHttpClientRequestCustomizer bean, and that's one bean applied to every connection. It can't close over a name because it was constructed before any connection existed.

    The transport context covers that case too, but only if whoever built the client owns transportContextProvider. That setter assigns rather than composes (McpClient.java#L503), so an application that sets its own silently drops ours. It's also sync only, AsyncSpec has no equivalent. And there's no agreed key for the connection name. We made up our own string, so an application customizer has to read a key that's specific to us, and anyone else doing the same thing picks a different one. A key defined by the SDK would be something a customizer could read, no matter who built the client.

    A signature change would be a breaking change and not suitable for a P3. The minimal change that satisfies the ask is static metadata on the transport builder:

    HttpClientStreamableHttpTransport.builder(url)
        .metadata(Map.of("connectionName", "first-server"))
        .build();

    merged into the context the transport already extracts before each customize(...) call. One transport is one connection, so the name is always right for the connection the customizer is on. It doesn't go through transportContextProvider, so an application setting its own can't drop it. And it works for async today, without #1075. If the SDK documents the key name, that covers the naming point too.

  5. Kehrlann commented on Aug 26, 2026

    @Kehrlann
    Contributor

    Let's step back.

    Why does the customizer need to know which transport a request is linked to? What exactly are you trying to accomplish? What's your use-case?

    (If you are using an AI agent, please refrain from explaining all the details and focus on your use-case. For example, avoid "Someone doing X would result in Y" unless it is something your users actually do)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions