Skip to content

Commit 87bdf1e

Browse files
authored
Client transports: make #protocolVersions() configurable (#669)
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent bc30857 commit 87bdf1e

File tree

8 files changed

+580
-80
lines changed

8 files changed

+580
-80
lines changed

mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,21 @@
1111
import java.net.http.HttpResponse;
1212
import java.net.http.HttpResponse.BodyHandler;
1313
import java.time.Duration;
14+
import java.util.Collections;
15+
import java.util.Comparator;
1416
import java.util.List;
1517
import java.util.Optional;
1618
import java.util.concurrent.CompletionException;
1719
import java.util.concurrent.atomic.AtomicReference;
1820
import java.util.function.Consumer;
1921
import java.util.function.Function;
2022

21-
import org.reactivestreams.Publisher;
22-
import org.slf4j.Logger;
23-
import org.slf4j.LoggerFactory;
24-
25-
import io.modelcontextprotocol.json.TypeRef;
26-
import io.modelcontextprotocol.json.McpJsonMapper;
27-
23+
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
2824
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
2925
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
30-
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
3126
import io.modelcontextprotocol.common.McpTransportContext;
27+
import io.modelcontextprotocol.json.McpJsonMapper;
28+
import io.modelcontextprotocol.json.TypeRef;
3229
import io.modelcontextprotocol.spec.ClosedMcpTransportSession;
3330
import io.modelcontextprotocol.spec.DefaultMcpTransportSession;
3431
import io.modelcontextprotocol.spec.DefaultMcpTransportStream;
@@ -42,6 +39,9 @@
4239
import io.modelcontextprotocol.spec.ProtocolVersions;
4340
import io.modelcontextprotocol.util.Assert;
4441
import io.modelcontextprotocol.util.Utils;
42+
import org.reactivestreams.Publisher;
43+
import org.slf4j.Logger;
44+
import org.slf4j.LoggerFactory;
4545
import reactor.core.Disposable;
4646
import reactor.core.publisher.Flux;
4747
import reactor.core.publisher.FluxSink;
@@ -78,8 +78,6 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
7878

7979
private static final Logger logger = LoggerFactory.getLogger(HttpClientStreamableHttpTransport.class);
8080

81-
private static final String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_06_18;
82-
8381
private static final String DEFAULT_ENDPOINT = "/mcp";
8482

8583
/**
@@ -125,9 +123,14 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
125123

126124
private final AtomicReference<Consumer<Throwable>> exceptionHandler = new AtomicReference<>();
127125

126+
private final List<String> supportedProtocolVersions;
127+
128+
private final String latestSupportedProtocolVersion;
129+
128130
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
129131
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
130-
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer) {
132+
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer,
133+
List<String> supportedProtocolVersions) {
131134
this.jsonMapper = jsonMapper;
132135
this.httpClient = httpClient;
133136
this.requestBuilder = requestBuilder;
@@ -137,12 +140,16 @@ private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient h
137140
this.openConnectionOnStartup = openConnectionOnStartup;
138141
this.activeSession.set(createTransportSession());
139142
this.httpRequestCustomizer = httpRequestCustomizer;
143+
this.supportedProtocolVersions = Collections.unmodifiableList(supportedProtocolVersions);
144+
this.latestSupportedProtocolVersion = this.supportedProtocolVersions.stream()
145+
.sorted(Comparator.reverseOrder())
146+
.findFirst()
147+
.get();
140148
}
141149

142150
@Override
143151
public List<String> protocolVersions() {
144-
return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26,
145-
ProtocolVersions.MCP_2025_06_18);
152+
return supportedProtocolVersions;
146153
}
147154

148155
public static Builder builder(String baseUri) {
@@ -186,7 +193,7 @@ private Publisher<Void> createDelete(String sessionId) {
186193
.uri(uri)
187194
.header("Cache-Control", "no-cache")
188195
.header(HttpHeaders.MCP_SESSION_ID, sessionId)
189-
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
196+
.header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion)
190197
.DELETE();
191198
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
192199
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext));
@@ -257,7 +264,7 @@ private Mono<Disposable> reconnect(McpTransportStream<Disposable> stream) {
257264
var builder = requestBuilder.uri(uri)
258265
.header(HttpHeaders.ACCEPT, TEXT_EVENT_STREAM)
259266
.header("Cache-Control", "no-cache")
260-
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
267+
.header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion)
261268
.GET();
262269
var transportContext = connectionCtx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
263270
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
@@ -432,7 +439,7 @@ public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
432439
.header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
433440
.header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
434441
.header(HttpHeaders.CACHE_CONTROL, "no-cache")
435-
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
442+
.header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion)
436443
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
437444
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
438445
return Mono
@@ -624,6 +631,9 @@ public static class Builder {
624631

625632
private Duration connectTimeout = Duration.ofSeconds(10);
626633

634+
private List<String> supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
635+
ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18);
636+
627637
/**
628638
* Creates a new builder with the specified base URI.
629639
* @param baseUri the base URI of the MCP server
@@ -772,6 +782,30 @@ public Builder connectTimeout(Duration connectTimeout) {
772782
return this;
773783
}
774784

785+
/**
786+
* Sets the list of supported protocol versions used in version negotiation. By
787+
* default, the client will send the latest of those versions in the
788+
* {@code MCP-Protocol-Version} header.
789+
* <p>
790+
* Setting this value only updates the values used in version negotiation, and
791+
* does NOT impact the actual capabilities of the transport. It should only be
792+
* used for compatibility with servers having strict requirements around the
793+
* {@code MCP-Protocol-Version} header.
794+
* @param supportedProtocolVersions protocol versions supported by this transport
795+
* @return this builder
796+
* @see <a href=
797+
* "https://modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle#version-negotiation">version
798+
* negotiation specification</a>
799+
* @see <a href=
800+
* "https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header">Protocol
801+
* Version Header</a>
802+
*/
803+
public Builder supportedProtocolVersions(List<String> supportedProtocolVersions) {
804+
Assert.notEmpty(supportedProtocolVersions, "supportedProtocolVersions must not be empty");
805+
this.supportedProtocolVersions = Collections.unmodifiableList(supportedProtocolVersions);
806+
return this;
807+
}
808+
775809
/**
776810
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
777811
* the current builder configuration.
@@ -781,7 +815,7 @@ public HttpClientStreamableHttpTransport build() {
781815
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
782816
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper,
783817
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
784-
httpRequestCustomizer);
818+
httpRequestCustomizer, supportedProtocolVersions);
785819
}
786820

787821
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.common;
6+
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.function.BiFunction;
10+
11+
import io.modelcontextprotocol.client.McpClient;
12+
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
13+
import io.modelcontextprotocol.server.McpServer;
14+
import io.modelcontextprotocol.server.McpServerFeatures;
15+
import io.modelcontextprotocol.server.McpSyncServer;
16+
import io.modelcontextprotocol.server.McpSyncServerExchange;
17+
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
18+
import io.modelcontextprotocol.server.transport.McpTestRequestRecordingServletFilter;
19+
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
20+
import io.modelcontextprotocol.spec.McpSchema;
21+
import io.modelcontextprotocol.spec.ProtocolVersions;
22+
import org.apache.catalina.LifecycleException;
23+
import org.apache.catalina.LifecycleState;
24+
import org.apache.catalina.startup.Tomcat;
25+
import org.junit.jupiter.api.AfterEach;
26+
import org.junit.jupiter.api.Test;
27+
28+
import static org.assertj.core.api.Assertions.assertThat;
29+
30+
class HttpClientStreamableHttpVersionNegotiationIntegrationTests {
31+
32+
private Tomcat tomcat;
33+
34+
private static final int PORT = TomcatTestUtil.findAvailablePort();
35+
36+
private final McpTestRequestRecordingServletFilter requestRecordingFilter = new McpTestRequestRecordingServletFilter();
37+
38+
private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider
39+
.builder()
40+
.contextExtractor(
41+
req -> McpTransportContext.create(Map.of("protocol-version", req.getHeader("MCP-protocol-version"))))
42+
.build();
43+
44+
private final McpSchema.Tool toolSpec = McpSchema.Tool.builder()
45+
.name("test-tool")
46+
.description("return the protocol version used")
47+
.build();
48+
49+
private final BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> toolHandler = (
50+
exchange, request) -> new McpSchema.CallToolResult(
51+
exchange.transportContext().get("protocol-version").toString(), null);
52+
53+
McpSyncServer mcpServer = McpServer.sync(transport)
54+
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build())
55+
.tools(new McpServerFeatures.SyncToolSpecification(toolSpec, null, toolHandler))
56+
.build();
57+
58+
@AfterEach
59+
void tearDown() {
60+
stopTomcat();
61+
}
62+
63+
@Test
64+
void usesLatestVersion() {
65+
startTomcat();
66+
67+
var client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT).build())
68+
.build();
69+
70+
client.initialize();
71+
McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of()));
72+
73+
var calls = requestRecordingFilter.getCalls();
74+
75+
assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\""))
76+
// GET /mcp ; POST notification/initialized ; POST tools/call
77+
.hasSize(3)
78+
.map(McpTestRequestRecordingServletFilter.Call::headers)
79+
.allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version",
80+
ProtocolVersions.MCP_2025_06_18));
81+
82+
assertThat(response).isNotNull();
83+
assertThat(response.content()).hasSize(1)
84+
.first()
85+
.extracting(McpSchema.TextContent.class::cast)
86+
.extracting(McpSchema.TextContent::text)
87+
.isEqualTo(ProtocolVersions.MCP_2025_06_18);
88+
mcpServer.close();
89+
}
90+
91+
@Test
92+
void usesCustomLatestVersion() {
93+
startTomcat();
94+
95+
var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
96+
.supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_06_18, "2263-03-18"))
97+
.build();
98+
var client = McpClient.sync(transport).build();
99+
100+
client.initialize();
101+
McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of()));
102+
103+
var calls = requestRecordingFilter.getCalls();
104+
105+
assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\""))
106+
// GET /mcp ; POST notification/initialized ; POST tools/call
107+
.hasSize(3)
108+
.map(McpTestRequestRecordingServletFilter.Call::headers)
109+
.allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", "2263-03-18"));
110+
111+
assertThat(response).isNotNull();
112+
assertThat(response.content()).hasSize(1)
113+
.first()
114+
.extracting(McpSchema.TextContent.class::cast)
115+
.extracting(McpSchema.TextContent::text)
116+
.isEqualTo("2263-03-18");
117+
mcpServer.close();
118+
}
119+
120+
private void startTomcat() {
121+
tomcat = TomcatTestUtil.createTomcatServer("", PORT, transport, requestRecordingFilter);
122+
try {
123+
tomcat.start();
124+
assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
125+
}
126+
catch (Exception e) {
127+
throw new RuntimeException("Failed to start Tomcat", e);
128+
}
129+
}
130+
131+
private void stopTomcat() {
132+
if (tomcat != null) {
133+
try {
134+
tomcat.stop();
135+
tomcat.destroy();
136+
}
137+
catch (LifecycleException e) {
138+
throw new RuntimeException("Failed to stop Tomcat", e);
139+
}
140+
}
141+
}
142+
143+
}

0 commit comments

Comments
 (0)