feat: implement MCP protocol version per transport support (#404)

- Add protocolVersion() method to transport and transport provider interfaces and implementations
- Replace hardcoded HTTP header strings with HttpHeaders constants
- Update protocol versions to be transport-specific rather than global
- Deprecate McpSchema.LATEST_PROTOCOL_VERSION in favor of transport-specific versions
- Standardize header names: MCP-Protocol-Version, mcp-session-id, last-event-id
- Update clients and servers to use transport.protocolVersion() for initialization
- Refactor tests to use transport-specific protocol versions
- Include MCP-Protocol-Version header in all GET/POST/DELETE requests
- Update WebClientStreamableHttpTransport, WebFluxSseClientTransport,
  HttpClientSseClientTransport, and HttpClientStreamableHttpTransport

Related to #398 , #363 , #250

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-07-30 23:54:28 +02:00 • committed by GitHub
commit 9d58621918
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 169 additions and 34 deletions

View file

@ -272,9 +272,8 @@ public class McpAsyncClient {
asyncProgressNotificationHandler(progressConsumersFinal));
this.initializer = new LifecycleInitializer(clientCapabilities, clientInfo,
List.of(McpSchema.LATEST_PROTOCOL_VERSION), initializationTimeout,
ctx -> new McpClientSession(requestTimeout, transport, requestHandlers, notificationHandlers,
con -> con.contextWrite(ctx)));
List.of(transport.protocolVersion()), initializationTimeout, ctx -> new McpClientSession(requestTimeout,
transport, requestHandlers, notificationHandlers, con -> con.contextWrite(ctx)));
this.transport.setExceptionHandler(this.initializer::handleException);
}

View file

@ -61,6 +61,10 @@ import reactor.core.publisher.Sinks;
*/
public class HttpClientSseClientTransport implements McpClientTransport {
private static final String MCP_PROTOCOL_VERSION = "2024-11-05";
private static final String MCP_PROTOCOL_VERSION_HEADER_NAME = "MCP-Protocol-Version";
private static final Logger logger = LoggerFactory.getLogger(HttpClientSseClientTransport.class);
/** SSE event type for JSON-RPC messages */
@ -211,6 +215,11 @@ public class HttpClientSseClientTransport implements McpClientTransport {
this.httpRequestCustomizer = httpRequestCustomizer;
}
@Override
public String protocolVersion() {
return MCP_PROTOCOL_VERSION;
}
/**
* Creates a new builder for {@link HttpClientSseClientTransport}.
* @param baseUri the base URI of the MCP server
@ -391,6 +400,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
.uri(uri)
.header("Accept", "text/event-stream")
.header("Cache-Control", "no-cache")
.header(MCP_PROTOCOL_VERSION_HEADER_NAME, MCP_PROTOCOL_VERSION)
.GET();
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null));
}).flatMap(requestBuilder -> Mono.create(sink -> {
@ -516,7 +526,10 @@ public class HttpClientSseClientTransport implements McpClientTransport {
private Mono<HttpResponse<String>> sendHttpPost(final String endpoint, final String body) {
final URI requestUri = Utils.resolveUri(baseUri, endpoint);
return Mono.defer(() -> {
var builder = this.requestBuilder.copy().uri(requestUri).POST(HttpRequest.BodyPublishers.ofString(body));
var builder = this.requestBuilder.copy()
.uri(requestUri)
.header(MCP_PROTOCOL_VERSION_HEADER_NAME, MCP_PROTOCOL_VERSION)
.POST(HttpRequest.BodyPublishers.ofString(body));
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body));
}).flatMap(customizedBuilder -> {
var request = customizedBuilder.build();

View file

@ -28,6 +28,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
import io.modelcontextprotocol.spec.DefaultMcpTransportSession;
import io.modelcontextprotocol.spec.DefaultMcpTransportStream;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
@ -72,6 +73,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private static final Logger logger = LoggerFactory.getLogger(HttpClientStreamableHttpTransport.class);
private static final String MCP_PROTOCOL_VERSION = "2025-03-26";
private static final String DEFAULT_ENDPOINT = "/mcp";
/**
@ -131,6 +134,11 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
this.httpRequestCustomizer = httpRequestCustomizer;
}
@Override
public String protocolVersion() {
return MCP_PROTOCOL_VERSION;
}
public static Builder builder(String baseUri) {
return new Builder(baseUri);
}
@ -157,12 +165,14 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
}
private Publisher<Void> createDelete(String sessionId) {
var uri = Utils.resolveUri(this.baseUri, this.endpoint);
return Mono.defer(() -> {
var builder = this.requestBuilder.copy()
.uri(uri)
.header("Cache-Control", "no-cache")
.header("mcp-session-id", sessionId)
.header(HttpHeaders.MCP_SESSION_ID, sessionId)
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
.DELETE();
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null));
}).flatMap(requestBuilder -> {
@ -221,16 +231,18 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
HttpRequest.Builder requestBuilder = this.requestBuilder.copy();
if (transportSession != null && transportSession.sessionId().isPresent()) {
requestBuilder = requestBuilder.header("mcp-session-id", transportSession.sessionId().get());
requestBuilder = requestBuilder.header(HttpHeaders.MCP_SESSION_ID,
transportSession.sessionId().get());
}
if (stream != null && stream.lastId().isPresent()) {
requestBuilder = requestBuilder.header("last-event-id", stream.lastId().get());
requestBuilder = requestBuilder.header(HttpHeaders.LAST_EVENT_ID, stream.lastId().get());
}
var builder = requestBuilder.uri(uri)
.header("Accept", TEXT_EVENT_STREAM)
.header("Cache-Control", "no-cache")
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
.GET();
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null));
})
@ -377,13 +389,15 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
HttpRequest.Builder requestBuilder = this.requestBuilder.copy();
if (transportSession != null && transportSession.sessionId().isPresent()) {
requestBuilder = requestBuilder.header("mcp-session-id", transportSession.sessionId().get());
requestBuilder = requestBuilder.header(HttpHeaders.MCP_SESSION_ID,
transportSession.sessionId().get());
}
var builder = requestBuilder.uri(uri)
.header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
.header("Content-Type", APPLICATION_JSON)
.header("Cache-Control", "no-cache")
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, jsonBody));
}).flatMapMany(requestBuilder -> Flux.<ResponseEvent>create(responseEventSink -> {

View file

@ -115,7 +115,7 @@ public class McpAsyncServer {
private final ConcurrentHashMap<McpSchema.CompleteReference, McpServerFeatures.AsyncCompletionSpecification> completions = new ConcurrentHashMap<>();
private List<String> protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
private List<String> protocolVersions;
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DeafaultMcpUriTemplateManagerFactory();
@ -145,6 +145,8 @@ public class McpAsyncServer {
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
this.protocolVersions = List.of(mcpTransportProvider.protocolVersion());
mcpTransportProvider.setSessionFactory(transport -> new McpServerSession(UUID.randomUUID().toString(),
requestTimeout, transport, this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers));
}
@ -168,6 +170,8 @@ public class McpAsyncServer {
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
this.protocolVersions = List.of(mcpTransportProvider.protocolVersion());
mcpTransportProvider.setSessionFactory(new DefaultMcpStreamableServerSessionFactory(requestTimeout,
this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers));
}

View file

@ -63,7 +63,7 @@ public class McpStatelessAsyncServer {
private final ConcurrentHashMap<McpSchema.CompleteReference, McpStatelessServerFeatures.AsyncCompletionSpecification> completions = new ConcurrentHashMap<>();
private List<String> protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
private List<String> protocolVersions;
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DeafaultMcpUriTemplateManagerFactory();
@ -118,6 +118,8 @@ public class McpStatelessAsyncServer {
requestHandlers.put(McpSchema.METHOD_COMPLETION_COMPLETE, completionCompleteRequestHandler());
}
this.protocolVersions = List.of(mcpTransport.protocolVersion());
McpStatelessServerHandler handler = new DefaultMcpStatelessServerHandler(requestHandlers, Map.of());
mcpTransport.setMcpHandler(handler);
}

View file

@ -178,6 +178,11 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
}
@Override
public String protocolVersion() {
return "2024-11-05";
}
/**
* Creates a new HttpServletSseServerTransportProvider instance with the default SSE
* endpoint.

View file

@ -154,6 +154,11 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
@Override
public String protocolVersion() {
return "2025-03-26";
}
@Override
public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) {
this.sessionFactory = sessionFactory;

View file

@ -88,6 +88,11 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
this.outputStream = outputStream;
}
@Override
public String protocolVersion() {
return "2024-11-05";
}
@Override
public void setSessionFactory(McpServerSession.Factory sessionFactory) {
// Create a single session for the stdio connection

View file

@ -17,4 +17,9 @@ public interface HttpHeaders {
*/
String LAST_EVENT_ID = "last-event-id";
/**
* Identifies the MCP protocol version.
*/
String PROTOCOL_VERSION = "MCP-Protocol-Version";
}

View file

@ -44,6 +44,7 @@ public final class McpSchema {
private McpSchema() {
}
@Deprecated
public static final String LATEST_PROTOCOL_VERSION = "2025-03-26";
public static final String JSONRPC_VERSION = "2.0";

View file

@ -55,4 +55,12 @@ public interface McpServerTransportProviderBase {
*/
Mono<Void> closeGracefully();
/**
* Returns the protocol version supported by this transport provider.
* @return the protocol version as a string
*/
default String protocolVersion() {
return "2024-11-05";
}
}

View file

@ -22,4 +22,8 @@ public interface McpStatelessServerTransport {
*/
Mono<Void> closeGracefully();
default String protocolVersion() {
return "2025-03-26";
}
}

View file

@ -2,8 +2,6 @@ package io.modelcontextprotocol.spec;
import reactor.core.publisher.Mono;
import java.util.Map;
/**
* The core building block providing the server-side MCP transport for Streamable HTTP
* servers. Implement this interface to bridge between a particular server-side technology

View file

@ -77,4 +77,8 @@ public interface McpTransport {
*/
<T> T unmarshalFrom(Object data, TypeReference<T> typeRef);
default String protocolVersion() {
return "2024-11-05";
}
}

View file

@ -29,6 +29,8 @@ public class MockMcpClientTransport implements McpClientTransport {
private final BiConsumer<MockMcpClientTransport, McpSchema.JSONRPCMessage> interceptor;
private String protocolVersion = McpSchema.LATEST_PROTOCOL_VERSION;
public MockMcpClientTransport() {
this((t, msg) -> {
});
@ -38,6 +40,15 @@ public class MockMcpClientTransport implements McpClientTransport {
this.interceptor = interceptor;
}
public MockMcpClientTransport withProtocolVersion(String protocolVersion) {
return this;
}
@Override
public String protocolVersion() {
return protocolVersion;
}
public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
if (inbound.tryEmitNext(message).isFailure()) {
throw new RuntimeException("Failed to process incoming message " + message);

View file

@ -52,7 +52,7 @@ class McpAsyncClientResponseHandlerTests {
r.id(), mockInitResult, null);
t.simulateIncomingMessage(initResponse);
}
});
}).withProtocolVersion(McpSchema.LATEST_PROTOCOL_VERSION);
}
@Test
@ -79,7 +79,7 @@ class McpAsyncClientResponseHandlerTests {
// Verify initialization result
assertThat(result).isNotNull();
assertThat(result.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION);
assertThat(result.protocolVersion()).isEqualTo(transport.protocolVersion());
assertThat(result.capabilities()).isEqualTo(serverCapabilities);
assertThat(result.serverInfo()).isEqualTo(serverInfo);
assertThat(result.instructions()).isEqualTo("Test instructions");

View file

@ -20,8 +20,8 @@ class McpAsyncClientTests {
public static final McpSchema.ServerCapabilities MOCK_SERVER_CAPABILITIES = McpSchema.ServerCapabilities.builder()
.build();
public static final McpSchema.InitializeResult MOCK_INIT_RESULT = new McpSchema.InitializeResult(
McpSchema.LATEST_PROTOCOL_VERSION, MOCK_SERVER_CAPABILITIES, MOCK_SERVER_INFO, "Test instructions");
public static final McpSchema.InitializeResult MOCK_INIT_RESULT = new McpSchema.InitializeResult("2024-11-05",
MOCK_SERVER_CAPABILITIES, MOCK_SERVER_INFO, "Test instructions");
private static final String CONTEXT_KEY = "context.key";

View file

@ -41,14 +41,14 @@ class McpClientProtocolVersionTests {
McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest();
assertThat(request.params()).isInstanceOf(McpSchema.InitializeRequest.class);
McpSchema.InitializeRequest initRequest = (McpSchema.InitializeRequest) request.params();
assertThat(initRequest.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION);
assertThat(initRequest.protocolVersion()).isEqualTo(transport.protocolVersion());
transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, null,
new McpSchema.InitializeResult(transport.protocolVersion(), null,
new McpSchema.Implementation("test-server", "1.0.0"), null),
null));
}).assertNext(result -> {
assertThat(result.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION);
assertThat(result.protocolVersion()).isEqualTo(transport.protocolVersion());
}).verifyComplete();
}

View file

@ -45,7 +45,7 @@ class McpServerProtocolVersionTests {
assertThat(jsonResponse.id()).isEqualTo(requestId);
assertThat(jsonResponse.result()).isInstanceOf(McpSchema.InitializeResult.class);
McpSchema.InitializeResult result = (McpSchema.InitializeResult) jsonResponse.result();
assertThat(result.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION);
assertThat(result.protocolVersion()).isEqualTo(transportProvider.protocolVersion());
server.closeGracefully().subscribe();
}
@ -93,7 +93,7 @@ class McpServerProtocolVersionTests {
assertThat(jsonResponse.id()).isEqualTo(requestId);
assertThat(jsonResponse.result()).isInstanceOf(McpSchema.InitializeResult.class);
McpSchema.InitializeResult result = (McpSchema.InitializeResult) jsonResponse.result();
assertThat(result.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION);
assertThat(result.protocolVersion()).isEqualTo(transportProvider.protocolVersion());
server.closeGracefully().subscribe();
}