Client transports: make #protocolVersions() configurable (#669)

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2025-11-18 14:53:50 +01:00 • committed by GitHub
commit 87bdf1ee49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 580 additions and 80 deletions

View file

@ -5,6 +5,8 @@
package io.modelcontextprotocol.client.transport;
import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
@ -22,9 +24,8 @@ import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.spec.ClosedMcpTransportSession;
import io.modelcontextprotocol.spec.DefaultMcpTransportSession;
import io.modelcontextprotocol.spec.DefaultMcpTransportStream;
@ -76,8 +77,6 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
private static final Logger logger = LoggerFactory.getLogger(WebClientStreamableHttpTransport.class);
private static final String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_06_18;
private static final String DEFAULT_ENDPOINT = "/mcp";
/**
@ -105,20 +104,29 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
private final AtomicReference<Consumer<Throwable>> exceptionHandler = new AtomicReference<>();
private final List<String> supportedProtocolVersions;
private final String latestSupportedProtocolVersion;
private WebClientStreamableHttpTransport(McpJsonMapper jsonMapper, WebClient.Builder webClientBuilder,
String endpoint, boolean resumableStreams, boolean openConnectionOnStartup) {
String endpoint, boolean resumableStreams, boolean openConnectionOnStartup,
List<String> supportedProtocolVersions) {
this.jsonMapper = jsonMapper;
this.webClient = webClientBuilder.build();
this.endpoint = endpoint;
this.resumableStreams = resumableStreams;
this.openConnectionOnStartup = openConnectionOnStartup;
this.activeSession.set(createTransportSession());
this.supportedProtocolVersions = List.copyOf(supportedProtocolVersions);
this.latestSupportedProtocolVersion = this.supportedProtocolVersions.stream()
.sorted(Comparator.reverseOrder())
.findFirst()
.get();
}
@Override
public List<String> protocolVersions() {
return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26,
ProtocolVersions.MCP_2025_06_18);
return supportedProtocolVersions;
}
/**
@ -149,7 +157,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
: webClient.delete()
.uri(this.endpoint)
.header(HttpHeaders.MCP_SESSION_ID, sessionId)
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
.header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion)
.retrieve()
.toBodilessEntity()
.onErrorComplete(e -> {
@ -217,7 +225,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
Disposable connection = webClient.get()
.uri(this.endpoint)
.accept(MediaType.TEXT_EVENT_STREAM)
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
.header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion)
.headers(httpHeaders -> {
transportSession.sessionId().ifPresent(id -> httpHeaders.add(HttpHeaders.MCP_SESSION_ID, id));
if (stream != null) {
@ -283,7 +291,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
Disposable connection = webClient.post()
.uri(this.endpoint)
.accept(MediaType.APPLICATION_JSON, MediaType.TEXT_EVENT_STREAM)
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
.header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion)
.headers(httpHeaders -> {
transportSession.sessionId().ifPresent(id -> httpHeaders.add(HttpHeaders.MCP_SESSION_ID, id));
})
@ -495,6 +503,9 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
private boolean openConnectionOnStartup = false;
private List<String> supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18);
private Builder(WebClient.Builder webClientBuilder) {
Assert.notNull(webClientBuilder, "WebClient.Builder must not be null");
this.webClientBuilder = webClientBuilder;
@ -560,6 +571,30 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
return this;
}
/**
* Sets the list of supported protocol versions used in version negotiation. By
* default, the client will send the latest of those versions in the
* {@code MCP-Protocol-Version} header.
* <p>
* Setting this value only updates the values used in version negotiation, and
* does NOT impact the actual capabilities of the transport. It should only be
* used for compatibility with servers having strict requirements around the
* {@code MCP-Protocol-Version} header.
* @param supportedProtocolVersions protocol versions supported by this transport
* @return this builder
* @see <a href=
* "https://modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle#version-negotiation">version
* negotiation specification</a>
* @see <a href=
* "https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header">Protocol
* Version Header</a>
*/
public Builder supportedProtocolVersions(List<String> supportedProtocolVersions) {
Assert.notEmpty(supportedProtocolVersions, "supportedProtocolVersions must not be empty");
this.supportedProtocolVersions = Collections.unmodifiableList(supportedProtocolVersions);
return this;
}
/**
* Construct a fresh instance of {@link WebClientStreamableHttpTransport} using
* the current builder configuration.
@ -567,7 +602,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
*/
public WebClientStreamableHttpTransport build() {
return new WebClientStreamableHttpTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper,
webClientBuilder, endpoint, resumableStreams, openConnectionOnStartup);
webClientBuilder, endpoint, resumableStreams, openConnectionOnStartup, supportedProtocolVersions);
}
}

View file

@ -0,0 +1,146 @@
/*
* Copyright 2025-2025 the original author or authors.
*/
package io.modelcontextprotocol;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.server.TestUtil;
import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.ProtocolVersions;
import io.modelcontextprotocol.utils.McpTestRequestRecordingExchangeFilterFunction;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import static org.assertj.core.api.Assertions.assertThat;
class WebFluxStreamableHttpVersionNegotiationIntegrationTests {
private static final int PORT = TestUtil.findAvailablePort();
private DisposableServer httpServer;
private final McpTestRequestRecordingExchangeFilterFunction recordingFilterFunction = new McpTestRequestRecordingExchangeFilterFunction();
private final McpSchema.Tool toolSpec = McpSchema.Tool.builder()
.name("test-tool")
.description("return the protocol version used")
.build();
private final BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> toolHandler = (
exchange, request) -> new McpSchema.CallToolResult(
exchange.transportContext().get("protocol-version").toString(), null);
private final WebFluxStreamableServerTransportProvider mcpStreamableServerTransportProvider = WebFluxStreamableServerTransportProvider
.builder()
.contextExtractor(req -> McpTransportContext
.create(Map.of("protocol-version", req.headers().firstHeader("MCP-protocol-version"))))
.build();
private final McpSyncServer mcpServer = McpServer.sync(mcpStreamableServerTransportProvider)
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build())
.tools(new McpServerFeatures.SyncToolSpecification(toolSpec, null, toolHandler))
.build();
@BeforeEach
void setUp() {
RouterFunction<ServerResponse> filteredRouter = mcpStreamableServerTransportProvider.getRouterFunction()
.filter(recordingFilterFunction);
HttpHandler httpHandler = RouterFunctions.toHttpHandler(filteredRouter);
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
}
@AfterEach
public void after() {
if (httpServer != null) {
httpServer.disposeNow();
}
if (mcpServer != null) {
mcpServer.close();
}
}
@Test
void usesLatestVersion() {
var client = McpClient
.sync(WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
.build())
.requestTimeout(Duration.ofHours(10))
.build();
client.initialize();
McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of()));
var calls = recordingFilterFunction.getCalls();
assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\""))
// GET /mcp ; POST notification/initialized ; POST tools/call
.hasSize(3)
.map(McpTestRequestRecordingExchangeFilterFunction.Call::headers)
.allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version",
ProtocolVersions.MCP_2025_06_18));
assertThat(response).isNotNull();
assertThat(response.content()).hasSize(1)
.first()
.extracting(McpSchema.TextContent.class::cast)
.extracting(McpSchema.TextContent::text)
.isEqualTo(ProtocolVersions.MCP_2025_06_18);
mcpServer.close();
}
@Test
void usesCustomLatestVersion() {
var transport = WebClientStreamableHttpTransport
.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
.supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_06_18, "2263-03-18"))
.build();
var client = McpClient.sync(transport).requestTimeout(Duration.ofHours(10)).build();
client.initialize();
McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of()));
var calls = recordingFilterFunction.getCalls();
assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\""))
// GET /mcp ; POST notification/initialized ; POST tools/call
.hasSize(3)
.map(McpTestRequestRecordingExchangeFilterFunction.Call::headers)
.allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", "2263-03-18"));
assertThat(response).isNotNull();
assertThat(response.content()).hasSize(1)
.first()
.extracting(McpSchema.TextContent.class::cast)
.extracting(McpSchema.TextContent::text)
.isEqualTo("2263-03-18");
mcpServer.close();
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright 2025-2025 the original author or authors.
*/
package io.modelcontextprotocol.utils;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import reactor.core.publisher.Mono;
import org.springframework.web.reactive.function.server.HandlerFilterFunction;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Simple {@link HandlerFilterFunction} which records calls made to an MCP server.
*
* @author Daniel Garnier-Moiroux
*/
public class McpTestRequestRecordingExchangeFilterFunction implements HandlerFilterFunction {
private final List<Call> calls = new ArrayList<>();
@Override
public Mono<ServerResponse> filter(ServerRequest request, HandlerFunction next) {
Map<String, String> headers = request.headers()
.asHttpHeaders()
.keySet()
.stream()
.collect(Collectors.toMap(String::toLowerCase, k -> String.join(",", request.headers().header(k))));
var cr = request.bodyToMono(String.class).defaultIfEmpty("").map(body -> {
this.calls.add(new Call(headers, body));
return ServerRequest.from(request).body(body).build();
});
return cr.flatMap(next::handle);
}
public List<Call> getCalls() {
return List.copyOf(calls);
}
public record Call(Map<String, String> headers, String body) {
}
}