From 9bbfbe573abb5920c731b9b3fbdd0d86b2cb9f87 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Tue, 28 Oct 2025 14:26:21 +0100 Subject: [PATCH 01/83] Close McpTransportSession on transport close (#632) * Client HTTP transports: use McpTransportSession interface instead of concrete types * Streamable HTTP tranports: .closeGracefully closes the session Signed-off-by: Daniel Garnier-Moiroux --- .../HttpClientStreamableHttpTransport.java | 17 +++- .../spec/ClosedMcpTransportSession.java | 58 ++++++++++++++ .../McpTransportSessionClosedException.java | 23 ++++++ ...AbstractMcpAsyncClientResiliencyTests.java | 9 ++- ...HttpClientStreamableHttpTransportTest.java | 35 +++++++++ .../WebClientStreamableHttpTransport.java | 17 +++- .../WebClientStreamableHttpTransportTest.java | 77 +++++++++++++++++++ ...AbstractMcpAsyncClientResiliencyTests.java | 9 ++- 8 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/spec/ClosedMcpTransportSession.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionClosedException.java create mode 100644 mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index f4505c898..cd8fa171f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -29,6 +29,7 @@ import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.ClosedMcpTransportSession; import io.modelcontextprotocol.spec.DefaultMcpTransportSession; import io.modelcontextprotocol.spec.DefaultMcpTransportStream; import io.modelcontextprotocol.spec.HttpHeaders; @@ -118,7 +119,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer; - private final AtomicReference activeSession = new AtomicReference<>(); + private final AtomicReference> activeSession = new AtomicReference<>(); private final AtomicReference, Mono>> handler = new AtomicReference<>(); @@ -163,12 +164,20 @@ public Mono connect(Function, Mono createTransportSession() { Function> onClose = sessionId -> sessionId == null ? Mono.empty() : createDelete(sessionId); return new DefaultMcpTransportSession(onClose); } + private McpTransportSession createClosedSession(McpTransportSession existingSession) { + var existingSessionId = Optional.ofNullable(existingSession) + .filter(session -> !(session instanceof ClosedMcpTransportSession)) + .flatMap(McpTransportSession::sessionId) + .orElse(null); + return new ClosedMcpTransportSession<>(existingSessionId); + } + private Publisher createDelete(String sessionId) { var uri = Utils.resolveUri(this.baseUri, this.endpoint); @@ -210,9 +219,9 @@ private void handleException(Throwable t) { public Mono closeGracefully() { return Mono.defer(() -> { logger.debug("Graceful close triggered"); - DefaultMcpTransportSession currentSession = this.activeSession.getAndSet(createTransportSession()); + McpTransportSession currentSession = this.activeSession.getAndUpdate(this::createClosedSession); if (currentSession != null) { - return currentSession.closeGracefully(); + return Mono.from(currentSession.closeGracefully()); } return Mono.empty(); }); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/ClosedMcpTransportSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/ClosedMcpTransportSession.java new file mode 100644 index 000000000..b18364abb --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/ClosedMcpTransportSession.java @@ -0,0 +1,58 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ +package io.modelcontextprotocol.spec; + +import java.util.Optional; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; +import reactor.util.annotation.Nullable; + +/** + * Represents a closed MCP session, which may not be reused. All calls will throw a + * {@link McpTransportSessionClosedException}. + * + * @param the resource representing the connection that the transport + * manages. + * @author Daniel Garnier-Moiroux + */ +public class ClosedMcpTransportSession implements McpTransportSession { + + private final String sessionId; + + public ClosedMcpTransportSession(@Nullable String sessionId) { + this.sessionId = sessionId; + } + + @Override + public Optional sessionId() { + throw new McpTransportSessionClosedException(sessionId); + } + + @Override + public boolean markInitialized(String sessionId) { + throw new McpTransportSessionClosedException(sessionId); + } + + @Override + public void addConnection(CONNECTION connection) { + throw new McpTransportSessionClosedException(sessionId); + } + + @Override + public void removeConnection(CONNECTION connection) { + throw new McpTransportSessionClosedException(sessionId); + } + + @Override + public void close() { + + } + + @Override + public Publisher closeGracefully() { + return Mono.empty(); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionClosedException.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionClosedException.java new file mode 100644 index 000000000..60e2850b9 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpTransportSessionClosedException.java @@ -0,0 +1,23 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import reactor.util.annotation.Nullable; + +/** + * Exception thrown when trying to use an {@link McpTransportSession} that has been + * closed. + * + * @see ClosedMcpTransportSession + * @author Daniel Garnier-Moiroux + */ +public class McpTransportSessionClosedException extends RuntimeException { + + public McpTransportSessionClosedException(@Nullable String sessionId) { + super(sessionId != null ? "MCP session with ID %s has been closed".formatted(sessionId) + : "MCP session has been closed"); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java index 3dbd413af..183b8a365 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java @@ -10,7 +10,7 @@ import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpTransport; -import org.junit.jupiter.api.Disabled; +import io.modelcontextprotocol.spec.McpTransportSessionClosedException; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -222,9 +222,10 @@ void testSessionClose() { // In case of Streamable HTTP this call should issue a HTTP DELETE request // invalidating the session StepVerifier.create(mcpAsyncClient.closeGracefully()).expectComplete().verify(); - // The next use should immediately re-initialize with no issue and send the - // request without any broken connections. - StepVerifier.create(mcpAsyncClient.ping()).expectNextCount(1).verifyComplete(); + // The next tries to use the closed session and fails + StepVerifier.create(mcpAsyncClient.ping()) + .expectErrorMatches(err -> err.getCause() instanceof McpTransportSessionClosedException) + .verify(); }); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index 0a09766d1..a1feb1f0e 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java @@ -125,4 +125,39 @@ void testAsyncRequestCustomizer() throws URISyntaxException { }); } + @Test + void testCloseUninitialized() { + var transport = HttpClientStreamableHttpTransport.builder(host).build(); + + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + + var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, + McpSchema.ClientCapabilities.builder().roots(true).build(), + new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, + "test-id", initializeRequest); + + StepVerifier.create(transport.sendMessage(testMessage)) + .expectErrorMessage("MCP session has been closed") + .verify(); + } + + @Test + void testCloseInitialized() { + var transport = HttpClientStreamableHttpTransport.builder(host).build(); + + var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, + McpSchema.ClientCapabilities.builder().roots(true).build(), + new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, + "test-id", initializeRequest); + + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + + StepVerifier.create(transport.sendMessage(testMessage)) + .expectErrorMatches(err -> err.getMessage().matches("MCP session with ID [a-zA-Z0-9-]* has been closed")) + .verify(); + } + } diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java index f6ce9b4d3..5ec272961 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java @@ -25,6 +25,7 @@ import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.spec.ClosedMcpTransportSession; import io.modelcontextprotocol.spec.DefaultMcpTransportSession; import io.modelcontextprotocol.spec.DefaultMcpTransportStream; import io.modelcontextprotocol.spec.HttpHeaders; @@ -98,7 +99,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport { private final boolean resumableStreams; - private final AtomicReference activeSession = new AtomicReference<>(); + private final AtomicReference> activeSession = new AtomicReference<>(); private final AtomicReference, Mono>> handler = new AtomicReference<>(); @@ -143,7 +144,7 @@ public Mono connect(Function, Mono createTransportSession() { Function> onClose = sessionId -> sessionId == null ? Mono.empty() : webClient.delete() .uri(this.endpoint) @@ -159,6 +160,14 @@ private DefaultMcpTransportSession createTransportSession() { return new DefaultMcpTransportSession(onClose); } + private McpTransportSession createClosedSession(McpTransportSession existingSession) { + var existingSessionId = Optional.ofNullable(existingSession) + .filter(session -> !(session instanceof ClosedMcpTransportSession)) + .flatMap(McpTransportSession::sessionId) + .orElse(null); + return new ClosedMcpTransportSession<>(existingSessionId); + } + @Override public void setExceptionHandler(Consumer handler) { logger.debug("Exception handler registered"); @@ -182,9 +191,9 @@ private void handleException(Throwable t) { public Mono closeGracefully() { return Mono.defer(() -> { logger.debug("Graceful close triggered"); - DefaultMcpTransportSession currentSession = this.activeSession.getAndSet(createTransportSession()); + McpTransportSession currentSession = this.activeSession.getAndUpdate(this::createClosedSession); if (currentSession != null) { - return currentSession.closeGracefully(); + return Mono.from(currentSession.closeGracefully()); } return Mono.empty(); }); diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java new file mode 100644 index 000000000..27a39387b --- /dev/null +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ +package io.modelcontextprotocol.client.transport; + +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import reactor.test.StepVerifier; + +import org.springframework.web.reactive.function.client.WebClient; + +class WebClientStreamableHttpTransportTest { + + static String host = "http://localhost:3001"; + + static WebClient.Builder builder; + + @SuppressWarnings("resource") + static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") + .withCommand("node dist/index.js streamableHttp") + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withExposedPorts(3001) + .waitingFor(Wait.forHttp("/").forStatusCode(404)); + + @BeforeAll + static void startContainer() { + container.start(); + int port = container.getMappedPort(3001); + host = "http://" + container.getHost() + ":" + port; + builder = WebClient.builder().baseUrl(host); + } + + @AfterAll + static void stopContainer() { + container.stop(); + } + + @Test + void testCloseUninitialized() { + var transport = WebClientStreamableHttpTransport.builder(builder).build(); + + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + + var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, + McpSchema.ClientCapabilities.builder().roots(true).build(), + new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, + "test-id", initializeRequest); + + StepVerifier.create(transport.sendMessage(testMessage)) + .expectErrorMessage("MCP session has been closed") + .verify(); + } + + @Test + void testCloseInitialized() { + var transport = WebClientStreamableHttpTransport.builder(builder).build(); + + var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, + McpSchema.ClientCapabilities.builder().roots(true).build(), + new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, + "test-id", initializeRequest); + + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + + StepVerifier.create(transport.sendMessage(testMessage)) + .expectErrorMatches(err -> err.getMessage().matches("MCP session with ID [a-zA-Z0-9-]* has been closed")) + .verify(); + } + +} diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java index d1f316094..d0b1c46a2 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java @@ -10,7 +10,7 @@ import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpTransport; -import org.junit.jupiter.api.Disabled; +import io.modelcontextprotocol.spec.McpTransportSessionClosedException; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -221,9 +221,10 @@ void testSessionClose() { // In case of Streamable HTTP this call should issue a HTTP DELETE request // invalidating the session StepVerifier.create(mcpAsyncClient.closeGracefully()).expectComplete().verify(); - // The next use should immediately re-initialize with no issue and send the - // request without any broken connections. - StepVerifier.create(mcpAsyncClient.ping()).expectNextCount(1).verifyComplete(); + // The next tries to use the closed session and fails + StepVerifier.create(mcpAsyncClient.ping()) + .expectErrorMatches(err -> err.getCause() instanceof McpTransportSessionClosedException) + .verify(); }); } From e91fe7fbc5a4981bf9395dfbc1a112a118830f79 Mon Sep 17 00:00:00 2001 From: lance Date: Thu, 30 Oct 2025 18:59:34 +0800 Subject: [PATCH 02/83] Fix: McpAsyncClient#listTools prevent infinite recursion (#631) * Fix: prevent infinite recursion in listTools() when nextCursor is empty string Signed-off-by: lance --- .../client/McpAsyncClient.java | 16 ++-- .../client/McpAsyncClientTests.java | 96 ++++++++++++++++--- 2 files changed, 90 insertions(+), 22 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 53a05aec3..2d1f4b43c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -402,6 +402,7 @@ public Mono closeGracefully() { // -------------------------- // Initialization // -------------------------- + /** * The initialization phase should be the first interaction between client and server. * The client will ensure it happens in case it has not been explicitly called and in @@ -448,6 +449,7 @@ public Mono ping() { // -------------------------- // Roots // -------------------------- + /** * Adds a new root to the client's root list. * @param root The root to add. @@ -625,13 +627,13 @@ private McpSchema.CallToolResult validateToolResult(String toolName, McpSchema.C * @return A Mono that emits the list of all tools result */ public Mono listTools() { - return this.listTools(McpSchema.FIRST_PAGE) - .expand(result -> (result.nextCursor() != null) ? this.listTools(result.nextCursor()) : Mono.empty()) - .reduce(new McpSchema.ListToolsResult(new ArrayList<>(), null), (allToolsResult, result) -> { - allToolsResult.tools().addAll(result.tools()); - return allToolsResult; - }) - .map(result -> new McpSchema.ListToolsResult(Collections.unmodifiableList(result.tools()), null)); + return this.listTools(McpSchema.FIRST_PAGE).expand(result -> { + String next = result.nextCursor(); + return (next != null && !next.isEmpty()) ? this.listTools(next) : Mono.empty(); + }).reduce(new McpSchema.ListToolsResult(new ArrayList<>(), null), (allToolsResult, result) -> { + allToolsResult.tools().addAll(result.tools()); + return allToolsResult; + }).map(result -> new McpSchema.ListToolsResult(Collections.unmodifiableList(result.tools()), null)); } /** diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java index 970d8f257..48bf1da5b 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java @@ -4,24 +4,22 @@ package io.modelcontextprotocol.client; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; + import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.ProtocolVersions; - import org.junit.jupiter.api.Test; - -import com.fasterxml.jackson.core.JsonProcessingException; - import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Function; - import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -40,8 +38,7 @@ class McpAsyncClientTests { private static final String CONTEXT_KEY = "context.key"; - private McpClientTransport createMockTransportForToolValidation(boolean hasOutputSchema, boolean invalidOutput) - throws JsonProcessingException { + private McpClientTransport createMockTransportForToolValidation(boolean hasOutputSchema, boolean invalidOutput) { // Create tool with or without output schema Map inputSchemaMap = Map.of("type", "object", "properties", @@ -182,7 +179,7 @@ public java.lang.reflect.Type getType() { } @Test - void testCallToolWithOutputSchemaValidationSuccess() throws JsonProcessingException { + void testCallToolWithOutputSchemaValidationSuccess() { McpClientTransport transport = createMockTransportForToolValidation(true, false); McpAsyncClient client = McpClient.async(transport).enableCallToolSchemaCaching(true).build(); @@ -204,7 +201,7 @@ void testCallToolWithOutputSchemaValidationSuccess() throws JsonProcessingExcept } @Test - void testCallToolWithNoOutputSchemaSuccess() throws JsonProcessingException { + void testCallToolWithNoOutputSchemaSuccess() { McpClientTransport transport = createMockTransportForToolValidation(false, false); McpAsyncClient client = McpClient.async(transport).enableCallToolSchemaCaching(true).build(); @@ -226,7 +223,7 @@ void testCallToolWithNoOutputSchemaSuccess() throws JsonProcessingException { } @Test - void testCallToolWithOutputSchemaValidationFailure() throws JsonProcessingException { + void testCallToolWithOutputSchemaValidationFailure() { McpClientTransport transport = createMockTransportForToolValidation(true, true); McpAsyncClient client = McpClient.async(transport).enableCallToolSchemaCaching(true).build(); @@ -241,4 +238,73 @@ void testCallToolWithOutputSchemaValidationFailure() throws JsonProcessingExcept StepVerifier.create(client.closeGracefully()).verifyComplete(); } + @Test + void testListToolsWithEmptyCursor() { + McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build(); + McpSchema.Tool subtractTool = McpSchema.Tool.builder() + .name("subtract") + .description("calculate subtract") + .build(); + McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool, subtractTool), ""); + + McpClientTransport transport = new McpClientTransport() { + Function, Mono> handler; + + @Override + public Mono connect( + Function, Mono> handler) { + return Mono.deferContextual(ctx -> { + this.handler = handler; + return Mono.empty(); + }); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + + McpSchema.JSONRPCResponse response; + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT, + null); + } + else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { + response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult, + null); + } + else { + return Mono.empty(); + } + + return handler.apply(Mono.just(response)).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpAsyncClient client = McpClient.async(transport).enableCallToolSchemaCaching(true).build(); + + Mono mono = client.listTools(); + McpSchema.ListToolsResult toolsResult = mono.block(); + assertThat(toolsResult).isNotNull(); + + Set names = toolsResult.tools().stream().map(McpSchema.Tool::name).collect(Collectors.toSet()); + assertThat(names).containsExactlyInAnyOrder("subtract", "add"); + } + } From 5c46626260be588829a514a7a02b30d8c7903eaa Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:17:42 +0100 Subject: [PATCH 03/83] chore: standardize client/server implementation names and version (#642) Unifies client/server implementation names under "Java SDK" branding and updates all versions to 0.15.0. Resolves: #638 Signed-off-by: Christian Tzolov --- .../io/modelcontextprotocol/client/McpClient.java | 4 ++-- .../io/modelcontextprotocol/server/McpServer.java | 3 +-- ...StreamableHttpTransportEmptyJsonResponseTest.java | 4 ++-- .../HttpClientStreamableHttpTransportTest.java | 12 ++++++------ .../WebClientStreamableHttpTransportTest.java | 4 ++-- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index e39d43e27..421f2fc7f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -167,7 +167,7 @@ class SyncSpec { private ClientCapabilities capabilities; - private Implementation clientInfo = new Implementation("Java SDK MCP Client", "1.0.0"); + private Implementation clientInfo = new Implementation("Java SDK MCP Client", "0.15.0"); private final Map roots = new HashMap<>(); @@ -507,7 +507,7 @@ class AsyncSpec { private ClientCapabilities capabilities; - private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1"); + private Implementation clientInfo = new Implementation("Java SDK MCP Client", "0.15.0"); private final Map roots = new HashMap<>(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index ecfb74b6a..047462ae4 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -11,7 +11,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.function.BiConsumer; import java.util.function.BiFunction; @@ -134,7 +133,7 @@ */ public interface McpServer { - McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server", "1.0.0"); + McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("Java SDK MCP Server", "0.15.0"); /** * Starts building a synchronous MCP server that provides blocking operations. diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java index 250c7aa50..81e642681 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java @@ -78,7 +78,7 @@ void testNotificationInitialized() throws URISyntaxException { var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26, McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + new McpSchema.Implementation("MCP Client", "0.3.1")); var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); @@ -86,7 +86,7 @@ void testNotificationInitialized() throws URISyntaxException { // Verify the customizer was called verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq( - "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"Spring AI MCP Client\",\"version\":\"0.3.1\"}}}"), + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), any()); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index a1feb1f0e..f9536b690 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java @@ -80,7 +80,7 @@ void testRequestCustomizer() throws URISyntaxException { // Send test message var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + new McpSchema.Implementation("MCP Client", "0.3.1")); var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); @@ -90,7 +90,7 @@ void testRequestCustomizer() throws URISyntaxException { // Verify the customizer was called verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq( - "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"Spring AI MCP Client\",\"version\":\"0.3.1\"}}}"), + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), eq(context)); }); } @@ -110,7 +110,7 @@ void testAsyncRequestCustomizer() throws URISyntaxException { // Send test message var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + new McpSchema.Implementation("MCP Client", "0.3.1")); var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); @@ -120,7 +120,7 @@ void testAsyncRequestCustomizer() throws URISyntaxException { // Verify the customizer was called verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq( - "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"Spring AI MCP Client\",\"version\":\"0.3.1\"}}}"), + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), eq(context)); }); } @@ -133,7 +133,7 @@ void testCloseUninitialized() { var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + new McpSchema.Implementation("MCP Client", "0.3.1")); var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); @@ -148,7 +148,7 @@ void testCloseInitialized() { var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + new McpSchema.Implementation("MCP Client", "0.3.1")); var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java index 27a39387b..e2fcf91f7 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java @@ -47,7 +47,7 @@ void testCloseUninitialized() { var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + new McpSchema.Implementation("MCP Client", "0.3.1")); var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); @@ -62,7 +62,7 @@ void testCloseInitialized() { var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("Spring AI MCP Client", "0.3.1")); + new McpSchema.Implementation("MCP Client", "0.3.1")); var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id", initializeRequest); From 0980f08e94761496282e17d65934f6ab3a33c988 Mon Sep 17 00:00:00 2001 From: Dmitry Bedrin Date: Thu, 30 Oct 2025 16:26:25 +0100 Subject: [PATCH 04/83] Update dependencies minor versions to fix build on JDK 25 (#640) --- pom.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index f17b2b61b..2cd141863 100644 --- a/pom.xml +++ b/pom.xml @@ -59,16 +59,16 @@ 17 - 3.26.3 + 3.27.6 5.10.2 - 5.17.0 + 5.20.0 1.20.4 - 1.17.5 + 1.17.8 1.21.0 2.0.16 1.5.15 - 2.17.0 + 2.19.2 6.2.1 From 14ff4a385dc8b953886a56966b675a0794b72638 Mon Sep 17 00:00:00 2001 From: Dmitry Bedrin Date: Thu, 30 Oct 2025 16:27:08 +0100 Subject: [PATCH 05/83] Support o.s.h.HttpHeaders from both 6.x and 7.x branches to enable migration to Spring 7 (#639) --- .../WebFluxStreamableServerTransportProvider.java | 8 ++++---- .../WebMvcStreamableServerTransportProvider.java | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java index 144a3ce02..deebfc616 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java @@ -174,7 +174,7 @@ private Mono handleGet(ServerRequest request) { return ServerResponse.badRequest().build(); } - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) { return ServerResponse.badRequest().build(); // TODO: say we need a session // id } @@ -187,7 +187,7 @@ private Mono handleGet(ServerRequest request) { return ServerResponse.notFound().build(); } - if (request.headers().asHttpHeaders().containsKey(HttpHeaders.LAST_EVENT_ID)) { + if (!request.headers().header(HttpHeaders.LAST_EVENT_ID).isEmpty()) { String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID); return ServerResponse.ok() .contentType(MediaType.TEXT_EVENT_STREAM) @@ -258,7 +258,7 @@ private Mono handlePost(ServerRequest request) { .bodyValue(initResult)); } - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) { return ServerResponse.badRequest().bodyValue(new McpError("Session ID missing")); } @@ -313,7 +313,7 @@ private Mono handleDelete(ServerRequest request) { McpTransportContext transportContext = this.contextExtractor.extract(request); return Mono.defer(() -> { - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) { return ServerResponse.badRequest().build(); // TODO: say we need a session // id } diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java index d85046a67..f2a58d4d8 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java @@ -240,7 +240,7 @@ private ServerResponse handleGet(ServerRequest request) { McpTransportContext transportContext = this.contextExtractor.extract(request); - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) { return ServerResponse.badRequest().body("Session ID required in mcp-session-id header"); } @@ -263,7 +263,7 @@ private ServerResponse handleGet(ServerRequest request) { sessionId, sseBuilder); // Check if this is a replay request - if (request.headers().asHttpHeaders().containsKey(HttpHeaders.LAST_EVENT_ID)) { + if (!request.headers().header(HttpHeaders.LAST_EVENT_ID).isEmpty()) { String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID); try { @@ -354,7 +354,7 @@ private ServerResponse handlePost(ServerRequest request) { } // Handle other messages that require a session - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) { return ServerResponse.badRequest().body(new McpError("Session ID missing")); } @@ -433,7 +433,7 @@ private ServerResponse handleDelete(ServerRequest request) { McpTransportContext transportContext = this.contextExtractor.extract(request); - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) { return ServerResponse.badRequest().body("Session ID required in mcp-session-id header"); } From 6e9af40bcbb2b5ee12748f7c5f20b0a7d479c5f8 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 30 Oct 2025 19:02:32 +0100 Subject: [PATCH 06/83] Next development version Signed-off-by: Christian Tzolov --- mcp-bom/pom.xml | 2 +- mcp-core/pom.xml | 6 +++--- mcp-json-jackson2/pom.xml | 4 ++-- mcp-json/pom.xml | 2 +- mcp-spring/mcp-spring-webflux/pom.xml | 8 ++++---- mcp-spring/mcp-spring-webmvc/pom.xml | 10 +++++----- mcp-test/pom.xml | 4 ++-- mcp/pom.xml | 6 +++--- pom.xml | 2 +- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index 3c83afda3..fc08f3d67 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT mcp-bom diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index 5681385b6..6ac8c2aba 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT mcp-core jar @@ -68,7 +68,7 @@ io.modelcontextprotocol.sdk mcp-json - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT @@ -101,7 +101,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT test diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index e53d5e57b..7e7000b97 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT mcp-json-jackson2 jar @@ -37,7 +37,7 @@ io.modelcontextprotocol.sdk mcp-json - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT com.fasterxml.jackson.core diff --git a/mcp-json/pom.xml b/mcp-json/pom.xml index 790b056c6..c12801fd4 100644 --- a/mcp-json/pom.xml +++ b/mcp-json/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT mcp-json jar diff --git a/mcp-spring/mcp-spring-webflux/pom.xml b/mcp-spring/mcp-spring-webflux/pom.xml index 785ebd746..c9b85f51d 100644 --- a/mcp-spring/mcp-spring-webflux/pom.xml +++ b/mcp-spring/mcp-spring-webflux/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT ../../pom.xml mcp-spring-webflux @@ -25,19 +25,19 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT io.modelcontextprotocol.sdk mcp - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-test - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT test diff --git a/mcp-spring/mcp-spring-webmvc/pom.xml b/mcp-spring/mcp-spring-webmvc/pom.xml index 3dc0e13f9..94b5c5881 100644 --- a/mcp-spring/mcp-spring-webmvc/pom.xml +++ b/mcp-spring/mcp-spring-webmvc/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT ../../pom.xml mcp-spring-webmvc @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT io.modelcontextprotocol.sdk mcp - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT @@ -43,14 +43,14 @@ io.modelcontextprotocol.sdk mcp-test - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT test io.modelcontextprotocol.sdk mcp-spring-webflux - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT test diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index 27560e10e..7618779b6 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT mcp-test jar @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk mcp - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT diff --git a/mcp/pom.xml b/mcp/pom.xml index 412f7dc6b..ce4fd7552 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT mcp jar @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-core - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT diff --git a/pom.xml b/pom.xml index 2cd141863..144a06c53 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.15.0-SNAPSHOT + 0.16.0-SNAPSHOT pom https://github.com/modelcontextprotocol/java-sdk From 476f9db36fa8cd54c059e620a9d2877586043d50 Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:56:21 +0200 Subject: [PATCH 07/83] feat: enhance error handling with custom error code preservation (#653) - Improve McpClientSession error handling to preserve custom error codes and data from McpError instances. - Add aggregated exception messages to error data field for better debugging. - Include test coverage for various McpClientSession error scenarios. Signed-off-by: Christian Tzolov --- .../spec/McpClientSession.java | 10 +- .../spec/McpClientSessionTests.java | 189 ++++++++++++++---- 2 files changed, 160 insertions(+), 39 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java index bc3f53467..0ba7ab3b8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java @@ -166,9 +166,15 @@ private void handle(McpSchema.JSONRPCMessage message) { else if (message instanceof McpSchema.JSONRPCRequest request) { logger.debug("Received request: {}", request); handleIncomingRequest(request).onErrorResume(error -> { + + McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (error instanceof McpError mcpError + && mcpError.getJsonRpcError() != null) ? mcpError.getJsonRpcError() + // TODO: add error message through the data field + : new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, + error.getMessage(), McpError.aggregateExceptionMessages(error)); + var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, - new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, - error.getMessage(), null)); + jsonRpcError); return Mono.just(errorResponse); }).flatMap(this.transport::sendMessage).onErrorComplete(t -> { logger.warn("Issue sending response to the client, ", t); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpClientSessionTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpClientSessionTests.java index 86912b4bf..3de06f503 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpClientSessionTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpClientSessionTests.java @@ -6,11 +6,10 @@ import java.time.Duration; import java.util.Map; +import java.util.function.Function; import io.modelcontextprotocol.MockMcpClientTransport; import io.modelcontextprotocol.json.TypeRef; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,7 +18,6 @@ import reactor.test.StepVerifier; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Test suite for {@link McpClientSession} that verifies its JSON-RPC message handling, @@ -39,35 +37,6 @@ class McpClientSessionTests { private static final String ECHO_METHOD = "echo"; - private McpClientSession session; - - private MockMcpClientTransport transport; - - @BeforeEach - void setUp() { - transport = new MockMcpClientTransport(); - session = new McpClientSession(TIMEOUT, transport, Map.of(), - Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: {}", params)))); - } - - @AfterEach - void tearDown() { - if (session != null) { - session.close(); - } - } - - @Test - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> new McpClientSession(null, transport, Map.of(), Map.of())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("The requestTimeout can not be null"); - - assertThatThrownBy(() -> new McpClientSession(TIMEOUT, null, Map.of(), Map.of())) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("transport can not be null"); - } - TypeRef responseType = new TypeRef<>() { }; @@ -76,6 +45,11 @@ void testSendRequest() { String testParam = "test parameter"; String responseData = "test response"; + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(), + Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: {}", params))), + Function.identity()); + // Create a Mono that will emit the response after the request is sent Mono responseMono = session.sendRequest(TEST_METHOD, testParam, responseType); // Verify response handling @@ -92,10 +66,17 @@ void testSendRequest() { assertThat(request.params()).isEqualTo(testParam); assertThat(response).isEqualTo(responseData); }).verifyComplete(); + + session.close(); } @Test void testSendRequestWithError() { + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(), + Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: {}", params))), + Function.identity()); + Mono responseMono = session.sendRequest(TEST_METHOD, "test", responseType); // Verify error handling @@ -107,20 +88,34 @@ void testSendRequestWithError() { transport.simulateIncomingMessage( new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, error)); }).expectError(McpError.class).verify(); + + session.close(); } @Test void testRequestTimeout() { + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(), + Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: {}", params))), + Function.identity()); + Mono responseMono = session.sendRequest(TEST_METHOD, "test", responseType); // Verify timeout StepVerifier.create(responseMono) .expectError(java.util.concurrent.TimeoutException.class) .verify(TIMEOUT.plusSeconds(1)); + + session.close(); } @Test void testSendNotification() { + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(), + Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: {}", params))), + Function.identity()); + Map params = Map.of("key", "value"); Mono notificationMono = session.sendNotification(TEST_NOTIFICATION, params); @@ -132,6 +127,8 @@ void testSendNotification() { assertThat(notification.method()).isEqualTo(TEST_NOTIFICATION); assertThat(notification.params()).isEqualTo(params); }).verifyComplete(); + + session.close(); } @Test @@ -139,8 +136,8 @@ void testRequestHandling() { String echoMessage = "Hello MCP!"; Map> requestHandlers = Map.of(ECHO_METHOD, params -> Mono.just(params)); - transport = new MockMcpClientTransport(); - session = new McpClientSession(TIMEOUT, transport, requestHandlers, Map.of()); + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, requestHandlers, Map.of(), Function.identity()); // Simulate incoming request McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, ECHO_METHOD, @@ -153,15 +150,18 @@ void testRequestHandling() { McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage; assertThat(response.result()).isEqualTo(echoMessage); assertThat(response.error()).isNull(); + + session.close(); } @Test void testNotificationHandling() { Sinks.One receivedParams = Sinks.one(); - transport = new MockMcpClientTransport(); - session = new McpClientSession(TIMEOUT, transport, Map.of(), - Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> receivedParams.tryEmitValue(params)))); + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(), + Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> receivedParams.tryEmitValue(params))), + Function.identity()); // Simulate incoming notification from the server Map notificationParams = Map.of("status", "ready"); @@ -173,10 +173,18 @@ void testNotificationHandling() { // Verify handler was called assertThat(receivedParams.asMono().block(Duration.ofSeconds(1))).isEqualTo(notificationParams); + + session.close(); } @Test void testUnknownMethodHandling() { + + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(), + Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: {}", params))), + Function.identity()); + // Simulate incoming request for unknown method McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "unknown.method", "test-id", null); @@ -188,10 +196,117 @@ void testUnknownMethodHandling() { McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage; assertThat(response.error()).isNotNull(); assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.METHOD_NOT_FOUND); + + session.close(); + } + + @Test + void testRequestHandlerThrowsMcpErrorWithJsonRpcError() { + // Setup: Create a request handler that throws McpError with custom error code and + // data + String testMethod = "test.customError"; + Map errorData = Map.of("customField", "customValue"); + McpClientSession.RequestHandler failingHandler = params -> Mono + .error(McpError.builder(123).message("Custom error message").data(errorData).build()); + + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(testMethod, failingHandler), Map.of(), + Function.identity()); + + // Simulate incoming request that will trigger the error + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, testMethod, + "test-id", null); + transport.simulateIncomingMessage(request); + + // Verify: The response should contain the custom error from McpError + McpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessage(); + assertThat(sentMessage).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage; + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(123); + assertThat(response.error().message()).isEqualTo("Custom error message"); + assertThat(response.error().data()).isEqualTo(errorData); + + session.close(); + } + + @Test + void testRequestHandlerThrowsGenericException() { + // Setup: Create a request handler that throws a generic RuntimeException + String testMethod = "test.genericError"; + RuntimeException exception = new RuntimeException("Something went wrong"); + McpClientSession.RequestHandler failingHandler = params -> Mono.error(exception); + + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(testMethod, failingHandler), Map.of(), + Function.identity()); + + // Simulate incoming request that will trigger the error + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, testMethod, + "test-id", null); + transport.simulateIncomingMessage(request); + + // Verify: The response should contain INTERNAL_ERROR with aggregated exception + // messages in data field + McpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessage(); + assertThat(sentMessage).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage; + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR); + assertThat(response.error().message()).isEqualTo("Something went wrong"); + // Verify data field contains aggregated exception messages + assertThat(response.error().data()).isNotNull(); + assertThat(response.error().data().toString()).contains("RuntimeException"); + assertThat(response.error().data().toString()).contains("Something went wrong"); + + session.close(); + } + + @Test + void testRequestHandlerThrowsExceptionWithCause() { + // Setup: Create a request handler that throws an exception with a cause chain + String testMethod = "test.chainedError"; + RuntimeException rootCause = new IllegalArgumentException("Root cause message"); + RuntimeException middleCause = new IllegalStateException("Middle cause message", rootCause); + RuntimeException topException = new RuntimeException("Top level message", middleCause); + McpClientSession.RequestHandler failingHandler = params -> Mono.error(topException); + + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(testMethod, failingHandler), Map.of(), + Function.identity()); + + // Simulate incoming request that will trigger the error + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, testMethod, + "test-id", null); + transport.simulateIncomingMessage(request); + + // Verify: The response should contain INTERNAL_ERROR with full exception chain + // in data field + McpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessage(); + assertThat(sentMessage).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage; + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR); + assertThat(response.error().message()).isEqualTo("Top level message"); + // Verify data field contains the full exception chain + String dataString = response.error().data().toString(); + assertThat(dataString).contains("RuntimeException"); + assertThat(dataString).contains("Top level message"); + assertThat(dataString).contains("IllegalStateException"); + assertThat(dataString).contains("Middle cause message"); + assertThat(dataString).contains("IllegalArgumentException"); + assertThat(dataString).contains("Root cause message"); + + session.close(); } @Test void testGracefulShutdown() { + var transport = new MockMcpClientTransport(); + var session = new McpClientSession(TIMEOUT, transport, Map.of(), + Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: {}", params))), + Function.identity()); + StepVerifier.create(session.closeGracefully()).verifyComplete(); } From ed85f7ee3ef778e15908140ada09fc9b3460112c Mon Sep 17 00:00:00 2001 From: Uladzislau Arlouski Date: Wed, 12 Nov 2025 16:21:09 +0300 Subject: [PATCH 08/83] Bump json-schema-validator from 1.5.7 to 2.0.0 (#660) --- .../jackson/DefaultJsonSchemaValidator.java | 36 +++++++++---------- pom.xml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java index 15511c9c2..1ff28cb80 100644 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java @@ -3,17 +3,17 @@ */ package io.modelcontextprotocol.json.schema.jackson; +import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.networknt.schema.JsonSchema; -import com.networknt.schema.JsonSchemaFactory; -import com.networknt.schema.SpecVersion; -import com.networknt.schema.ValidationMessage; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.Error; +import com.networknt.schema.dialect.Dialects; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,10 +31,10 @@ public class DefaultJsonSchemaValidator implements JsonSchemaValidator { private final ObjectMapper objectMapper; - private final JsonSchemaFactory schemaFactory; + private final SchemaRegistry schemaFactory; // TODO: Implement a strategy to purge the cache (TTL, size limit, etc.) - private final ConcurrentHashMap schemaCache; + private final ConcurrentHashMap schemaCache; public DefaultJsonSchemaValidator() { this(new ObjectMapper()); @@ -42,7 +42,7 @@ public DefaultJsonSchemaValidator() { public DefaultJsonSchemaValidator(ObjectMapper objectMapper) { this.objectMapper = objectMapper; - this.schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + this.schemaFactory = SchemaRegistry.withDialect(Dialects.getDraft202012()); this.schemaCache = new ConcurrentHashMap<>(); } @@ -62,7 +62,7 @@ public ValidationResponse validate(Map schema, Object structured ? this.objectMapper.readTree((String) structuredContent) : this.objectMapper.valueToTree(structuredContent); - Set validationResult = this.getOrCreateJsonSchema(schema).validate(jsonStructuredOutput); + List validationResult = this.getOrCreateJsonSchema(schema).validate(jsonStructuredOutput); // Check if validation passed if (!validationResult.isEmpty()) { @@ -85,36 +85,36 @@ public ValidationResponse validate(Map schema, Object structured } /** - * Gets a cached JsonSchema or creates and caches a new one. + * Gets a cached Schema or creates and caches a new one. * @param schema the schema map to convert - * @return the compiled JsonSchema + * @return the compiled Schema * @throws JsonProcessingException if schema processing fails */ - private JsonSchema getOrCreateJsonSchema(Map schema) throws JsonProcessingException { + private Schema getOrCreateJsonSchema(Map schema) throws JsonProcessingException { // Generate cache key based on schema content String cacheKey = this.generateCacheKey(schema); // Try to get from cache first - JsonSchema cachedSchema = this.schemaCache.get(cacheKey); + Schema cachedSchema = this.schemaCache.get(cacheKey); if (cachedSchema != null) { return cachedSchema; } // Create new schema if not in cache - JsonSchema newSchema = this.createJsonSchema(schema); + Schema newSchema = this.createJsonSchema(schema); // Cache the schema - JsonSchema existingSchema = this.schemaCache.putIfAbsent(cacheKey, newSchema); + Schema existingSchema = this.schemaCache.putIfAbsent(cacheKey, newSchema); return existingSchema != null ? existingSchema : newSchema; } /** - * Creates a new JsonSchema from the given schema map. + * Creates a new Schema from the given schema map. * @param schema the schema map - * @return the compiled JsonSchema + * @return the compiled Schema * @throws JsonProcessingException if schema processing fails */ - private JsonSchema createJsonSchema(Map schema) throws JsonProcessingException { + private Schema createJsonSchema(Map schema) throws JsonProcessingException { // Convert schema map directly to JsonNode (more efficient than string // serialization) JsonNode schemaNode = this.objectMapper.valueToTree(schema); diff --git a/pom.xml b/pom.xml index 144a06c53..1ff45596e 100644 --- a/pom.xml +++ b/pom.xml @@ -96,7 +96,7 @@ 4.2.0 7.1.0 4.1.0 - 1.5.7 + 2.0.0 From afa00902a4d7c225e1ec3947aae5011fe70deeaa Mon Sep 17 00:00:00 2001 From: Michael Vorburger Date: Wed, 12 Nov 2025 14:21:57 +0100 Subject: [PATCH 09/83] fix: JSONRPCResponse JavaDoc (#657) --- .../main/java/io/modelcontextprotocol/spec/McpSchema.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index e43469903..3f3f78df8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -277,12 +277,12 @@ public record JSONRPCNotification( // @formatter:off } /** - * A successful (non-error) response to a request. + * A response to a request (successful, or error). * * @param jsonrpc The JSON-RPC version (must be "2.0") * @param id The request identifier that this response corresponds to - * @param result The result of the successful request - * @param error Error information if the request failed + * @param result The result of the successful request; null if error + * @param error Error information if the request failed; null if has result */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) From 738525308d3d151d0af28cc560d0717e32856653 Mon Sep 17 00:00:00 2001 From: Viliam Sun Date: Wed, 12 Nov 2025 21:44:35 +0800 Subject: [PATCH 10/83] fix(docs): Remove broken @see link in resourceTemplates Javadoc (#614) --- .../main/java/io/modelcontextprotocol/server/McpServer.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index 047462ae4..87c84ba1b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -598,7 +598,6 @@ public AsyncSpecification resources(McpServerFeatures.AsyncResourceSpecificat * null. * @return This builder instance for method chaining * @throws IllegalArgumentException if resourceTemplates is null. - * @see #resourceTemplates(ResourceTemplate...) */ public AsyncSpecification resourceTemplates( List resourceTemplates) { @@ -1195,7 +1194,6 @@ public SyncSpecification resources(McpServerFeatures.SyncResourceSpecificatio * null. * @return This builder instance for method chaining * @throws IllegalArgumentException if resourceTemplates is null. - * @see #resourceTemplates(ResourceTemplate...) */ public SyncSpecification resourceTemplates( List resourceTemplates) { @@ -1703,7 +1701,6 @@ public StatelessAsyncSpecification resources( * templates. * @return This builder instance for method chaining * @throws IllegalArgumentException if resourceTemplates is null. - * @see #resourceTemplates(ResourceTemplate...) */ public StatelessAsyncSpecification resourceTemplates( List resourceTemplates) { @@ -2166,7 +2163,6 @@ public StatelessSyncSpecification resources( * existing templates. * @return This builder instance for method chaining * @throws IllegalArgumentException if resourceTemplates is null. - * @see #resourceTemplates(ResourceTemplate...) */ public StatelessSyncSpecification resourceTemplates( List resourceTemplatesSpec) { From b74f6de9dcc5ac1c6bdc6d2dd29c97d875574fcd Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:20:44 +0100 Subject: [PATCH 11/83] feat(schema): support Object type for progressToken (#663) Change progressToken from String to Object throughout McpSchema to allow both String and Number token types. This makes it compliant with the MCP spec. Resolves: #659 Signed-off-by: Christian Tzolov --- .../io/modelcontextprotocol/spec/McpSchema.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 3f3f78df8..342fc5347 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -167,9 +167,9 @@ public sealed interface Request extends Meta permits InitializeRequest, CallToolRequest, CreateMessageRequest, ElicitRequest, CompleteRequest, GetPromptRequest, ReadResourceRequest, SubscribeRequest, UnsubscribeRequest, PaginatedRequest { - default String progressToken() { + default Object progressToken() { if (meta() != null && meta().containsKey("progressToken")) { - return meta().get("progressToken").toString(); + return meta().get("progressToken"); } return null; } @@ -1502,7 +1502,7 @@ public Builder meta(Map meta) { return this; } - public Builder progressToken(String progressToken) { + public Builder progressToken(Object progressToken) { if (this.meta == null) { this.meta = new HashMap<>(); } @@ -1912,7 +1912,7 @@ public Builder meta(Map meta) { return this; } - public Builder progressToken(String progressToken) { + public Builder progressToken(Object progressToken) { if (this.meta == null) { this.meta = new HashMap<>(); } @@ -2080,7 +2080,7 @@ public Builder meta(Map meta) { return this; } - public Builder progressToken(String progressToken) { + public Builder progressToken(Object progressToken) { if (this.meta == null) { this.meta = new HashMap<>(); } @@ -2217,13 +2217,13 @@ public record PaginatedResult(@JsonProperty("nextCursor") String nextCursor) { @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record ProgressNotification( // @formatter:off - @JsonProperty("progressToken") String progressToken, + @JsonProperty("progressToken") Object progressToken, @JsonProperty("progress") Double progress, @JsonProperty("total") Double total, @JsonProperty("message") String message, @JsonProperty("_meta") Map meta) implements Notification { // @formatter:on - public ProgressNotification(String progressToken, double progress, Double total, String message) { + public ProgressNotification(Object progressToken, double progress, Double total, String message) { this(progressToken, progress, total, message, null); } } From e3079270d50dc035cbedde7102ce280bd23eaad3 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 12 Nov 2025 16:13:08 +0100 Subject: [PATCH 12/83] Next development version Signed-off-by: Christian Tzolov --- mcp-bom/pom.xml | 2 +- mcp-core/pom.xml | 6 +++--- mcp-json-jackson2/pom.xml | 4 ++-- mcp-json/pom.xml | 2 +- mcp-spring/mcp-spring-webflux/pom.xml | 8 ++++---- mcp-spring/mcp-spring-webmvc/pom.xml | 10 +++++----- mcp-test/pom.xml | 4 ++-- mcp/pom.xml | 6 +++--- pom.xml | 2 +- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index fc08f3d67..b06baea81 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT mcp-bom diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index 6ac8c2aba..39b4c9dc7 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT mcp-core jar @@ -68,7 +68,7 @@ io.modelcontextprotocol.sdk mcp-json - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT @@ -101,7 +101,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT test diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index 7e7000b97..8ea1fa7d2 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT mcp-json-jackson2 jar @@ -37,7 +37,7 @@ io.modelcontextprotocol.sdk mcp-json - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT com.fasterxml.jackson.core diff --git a/mcp-json/pom.xml b/mcp-json/pom.xml index c12801fd4..9fc850e11 100644 --- a/mcp-json/pom.xml +++ b/mcp-json/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT mcp-json jar diff --git a/mcp-spring/mcp-spring-webflux/pom.xml b/mcp-spring/mcp-spring-webflux/pom.xml index c9b85f51d..594d95750 100644 --- a/mcp-spring/mcp-spring-webflux/pom.xml +++ b/mcp-spring/mcp-spring-webflux/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT ../../pom.xml mcp-spring-webflux @@ -25,19 +25,19 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT io.modelcontextprotocol.sdk mcp - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-test - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT test diff --git a/mcp-spring/mcp-spring-webmvc/pom.xml b/mcp-spring/mcp-spring-webmvc/pom.xml index 94b5c5881..6460f652f 100644 --- a/mcp-spring/mcp-spring-webmvc/pom.xml +++ b/mcp-spring/mcp-spring-webmvc/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT ../../pom.xml mcp-spring-webmvc @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT io.modelcontextprotocol.sdk mcp - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT @@ -43,14 +43,14 @@ io.modelcontextprotocol.sdk mcp-test - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT test io.modelcontextprotocol.sdk mcp-spring-webflux - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT test diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index 7618779b6..3fbd028f4 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT mcp-test jar @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk mcp - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT diff --git a/mcp/pom.xml b/mcp/pom.xml index ce4fd7552..270dc2a1f 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT mcp jar @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-core - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT diff --git a/pom.xml b/pom.xml index 1ff45596e..ca9ce7be4 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.16.0-SNAPSHOT + 0.17.0-SNAPSHOT pom https://github.com/modelcontextprotocol/java-sdk From 2d22868e1ecc0908c1a1472f618ebfcb0ec7667c Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Tue, 18 Nov 2025 10:20:08 +0100 Subject: [PATCH 13/83] Fix experimental client capabilities tests (#670) Signed-off-by: Daniel Garnier-Moiroux --- .../client/AbstractMcpAsyncClientTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 859dc5f82..57a223ea2 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -673,7 +673,7 @@ void testInitializeWithElicitationCapability() { @Test void testInitializeWithAllCapabilities() { var capabilities = ClientCapabilities.builder() - .experimental(Map.of("feature", "test")) + .experimental(Map.of("feature", Map.of("featureFlag", true))) .roots(true) .sampling() .build(); From bc308573f3b4daf71fe4bb166f5f6e4c1c4bb9d7 Mon Sep 17 00:00:00 2001 From: mingo007 Date: Tue, 11 Nov 2025 22:00:30 +0800 Subject: [PATCH 14/83] Fix typo in test name: testPingWithEaxctExceptionType -> testPingWithExactExceptionType --- .../client/HttpSseMcpAsyncClientLostConnectionTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java index ba740518b..30e7fe913 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java @@ -122,7 +122,7 @@ void withClient(McpClientTransport transport, Consumer c) { } @Test - void testPingWithEaxctExceptionType() { + void testPingWithExactExceptionType() { withClient(HttpClientSseClientTransport.builder(host).build(), mcpAsyncClient -> { StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); From 87bdf1ee49bba050d112522ceed56dac3014fb4b Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Tue, 18 Nov 2025 14:53:50 +0100 Subject: [PATCH 15/83] Client transports: make #protocolVersions() configurable (#669) Signed-off-by: Daniel Garnier-Moiroux --- .../HttpClientStreamableHttpTransport.java | 68 ++++++-- ...ttpVersionNegotiationIntegrationTests.java | 143 +++++++++++++++++ .../McpTestRequestRecordingServletFilter.java | 128 +++++++++++++++ .../transport/McpTestServletFilter.java | 43 ------ .../server/transport/TomcatTestUtil.java | 22 +-- .../WebClientStreamableHttpTransport.java | 57 +++++-- ...ttpVersionNegotiationIntegrationTests.java | 146 ++++++++++++++++++ ...equestRecordingExchangeFilterFunction.java | 53 +++++++ 8 files changed, 580 insertions(+), 80 deletions(-) create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java delete mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestServletFilter.java create mode 100644 mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java create mode 100644 mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpTestRequestRecordingExchangeFilterFunction.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index cd8fa171f..c48aedbcf 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -11,6 +11,8 @@ import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandler; import java.time.Duration; +import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletionException; @@ -18,17 +20,12 @@ import java.util.function.Consumer; import java.util.function.Function; -import org.reactivestreams.Publisher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.modelcontextprotocol.json.TypeRef; -import io.modelcontextprotocol.json.McpJsonMapper; - +import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; -import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent; import io.modelcontextprotocol.common.McpTransportContext; +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; @@ -42,6 +39,9 @@ import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.Utils; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.Disposable; import reactor.core.publisher.Flux; import reactor.core.publisher.FluxSink; @@ -78,8 +78,6 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { private static final Logger logger = LoggerFactory.getLogger(HttpClientStreamableHttpTransport.class); - private static final String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_06_18; - private static final String DEFAULT_ENDPOINT = "/mcp"; /** @@ -125,9 +123,14 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { private final AtomicReference> exceptionHandler = new AtomicReference<>(); + private final List supportedProtocolVersions; + + private final String latestSupportedProtocolVersion; + private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams, - boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer) { + boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer, + List supportedProtocolVersions) { this.jsonMapper = jsonMapper; this.httpClient = httpClient; this.requestBuilder = requestBuilder; @@ -137,12 +140,16 @@ private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient h this.openConnectionOnStartup = openConnectionOnStartup; this.activeSession.set(createTransportSession()); this.httpRequestCustomizer = httpRequestCustomizer; + this.supportedProtocolVersions = Collections.unmodifiableList(supportedProtocolVersions); + this.latestSupportedProtocolVersion = this.supportedProtocolVersions.stream() + .sorted(Comparator.reverseOrder()) + .findFirst() + .get(); } @Override public List protocolVersions() { - return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, - ProtocolVersions.MCP_2025_06_18); + return supportedProtocolVersions; } public static Builder builder(String baseUri) { @@ -186,7 +193,7 @@ private Publisher createDelete(String sessionId) { .uri(uri) .header("Cache-Control", "no-cache") .header(HttpHeaders.MCP_SESSION_ID, sessionId) - .header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION) + .header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion) .DELETE(); var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext)); @@ -257,7 +264,7 @@ private Mono reconnect(McpTransportStream stream) { var builder = requestBuilder.uri(uri) .header(HttpHeaders.ACCEPT, TEXT_EVENT_STREAM) .header("Cache-Control", "no-cache") - .header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION) + .header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion) .GET(); var transportContext = connectionCtx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext)); @@ -432,7 +439,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { .header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM) .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) .header(HttpHeaders.CACHE_CONTROL, "no-cache") - .header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION) + .header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion) .POST(HttpRequest.BodyPublishers.ofString(jsonBody)); var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); return Mono @@ -624,6 +631,9 @@ public static class Builder { private Duration connectTimeout = Duration.ofSeconds(10); + private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05, + ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18); + /** * Creates a new builder with the specified base URI. * @param baseUri the base URI of the MCP server @@ -772,6 +782,30 @@ public Builder connectTimeout(Duration connectTimeout) { 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. + *

+ * 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 version + * negotiation specification + * @see Protocol + * Version Header + */ + public Builder supportedProtocolVersions(List supportedProtocolVersions) { + Assert.notEmpty(supportedProtocolVersions, "supportedProtocolVersions must not be empty"); + this.supportedProtocolVersions = Collections.unmodifiableList(supportedProtocolVersions); + return this; + } + /** * Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using * the current builder configuration. @@ -781,7 +815,7 @@ public HttpClientStreamableHttpTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup, - httpRequestCustomizer); + httpRequestCustomizer, supportedProtocolVersions); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java new file mode 100644 index 000000000..12a3ef9c6 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java @@ -0,0 +1,143 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package io.modelcontextprotocol.common; + +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.server.transport.McpTestRequestRecordingServletFilter; +import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.LifecycleState; +import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpClientStreamableHttpVersionNegotiationIntegrationTests { + + private Tomcat tomcat; + + private static final int PORT = TomcatTestUtil.findAvailablePort(); + + private final McpTestRequestRecordingServletFilter requestRecordingFilter = new McpTestRequestRecordingServletFilter(); + + private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider + .builder() + .contextExtractor( + req -> McpTransportContext.create(Map.of("protocol-version", req.getHeader("MCP-protocol-version")))) + .build(); + + private final McpSchema.Tool toolSpec = McpSchema.Tool.builder() + .name("test-tool") + .description("return the protocol version used") + .build(); + + private final BiFunction toolHandler = ( + exchange, request) -> new McpSchema.CallToolResult( + exchange.transportContext().get("protocol-version").toString(), null); + + McpSyncServer mcpServer = McpServer.sync(transport) + .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()) + .tools(new McpServerFeatures.SyncToolSpecification(toolSpec, null, toolHandler)) + .build(); + + @AfterEach + void tearDown() { + stopTomcat(); + } + + @Test + void usesLatestVersion() { + startTomcat(); + + var client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT).build()) + .build(); + + client.initialize(); + McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); + + var calls = requestRecordingFilter.getCalls(); + + assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\"")) + // GET /mcp ; POST notification/initialized ; POST tools/call + .hasSize(3) + .map(McpTestRequestRecordingServletFilter.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() { + startTomcat(); + + var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) + .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_06_18, "2263-03-18")) + .build(); + var client = McpClient.sync(transport).build(); + + client.initialize(); + McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); + + var calls = requestRecordingFilter.getCalls(); + + assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\"")) + // GET /mcp ; POST notification/initialized ; POST tools/call + .hasSize(3) + .map(McpTestRequestRecordingServletFilter.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(); + } + + private void startTomcat() { + tomcat = TomcatTestUtil.createTomcatServer("", PORT, transport, requestRecordingFilter); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + private void stopTomcat() { + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java new file mode 100644 index 000000000..09f0d305d --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java @@ -0,0 +1,128 @@ +/* + * Copyright 2025 - 2025 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +/** + * Simple {@link Filter} which records calls made to an MCP server. + * + * @author Daniel Garnier-Moiroux + */ +public class McpTestRequestRecordingServletFilter implements Filter { + + private final List calls = new ArrayList<>(); + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + + if (servletRequest instanceof HttpServletRequest req) { + var headers = Collections.list(req.getHeaderNames()) + .stream() + .collect(Collectors.toUnmodifiableMap(Function.identity(), + name -> String.join(",", Collections.list(req.getHeaders(name))))); + var request = new CachedBodyHttpServletRequest(req); + calls.add(new Call(headers, request.getBodyAsString())); + filterChain.doFilter(request, servletResponse); + } + else { + filterChain.doFilter(servletRequest, servletResponse); + } + + } + + public List getCalls() { + + return List.copyOf(calls); + } + + public record Call(Map headers, String body) { + + } + + public static class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private final byte[] cachedBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + this.cachedBody = request.getInputStream().readAllBytes(); + } + + @Override + public ServletInputStream getInputStream() { + return new CachedBodyServletInputStream(cachedBody); + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } + + public String getBodyAsString() { + return new String(cachedBody, StandardCharsets.UTF_8); + } + + } + + public static class CachedBodyServletInputStream extends ServletInputStream { + + private InputStream cachedBodyInputStream; + + public CachedBodyServletInputStream(byte[] cachedBody) { + this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody); + } + + @Override + public boolean isFinished() { + try { + return cachedBodyInputStream.available() == 0; + } + catch (IOException e) { + e.printStackTrace(); + } + return false; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() throws IOException { + return cachedBodyInputStream.read(); + } + + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestServletFilter.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestServletFilter.java deleted file mode 100644 index cc2543aa9..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestServletFilter.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2025 - 2025 the original author or authors. - */ - -package io.modelcontextprotocol.server.transport; - -import java.io.IOException; - -import jakarta.servlet.Filter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; - -/** - * Simple {@link Filter} which sets a value in a thread local. Used to verify whether MCP - * executions happen on the thread processing the request or are offloaded. - * - * @author Daniel Garnier-Moiroux - */ -public class McpTestServletFilter implements Filter { - - public static final String THREAD_LOCAL_VALUE = McpTestServletFilter.class.getName(); - - private static final ThreadLocal holder = new ThreadLocal<>(); - - @Override - public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) - throws IOException, ServletException { - holder.set(THREAD_LOCAL_VALUE); - try { - filterChain.doFilter(servletRequest, servletResponse); - } - finally { - holder.remove(); - } - } - - public static String getThreadLocalValue() { - return holder.get(); - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java index 2cf95dc94..490e29838 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java @@ -8,6 +8,7 @@ import java.net.InetSocketAddress; import java.net.ServerSocket; +import jakarta.servlet.Filter; import jakarta.servlet.Servlet; import org.apache.catalina.Context; import org.apache.catalina.startup.Tomcat; @@ -24,7 +25,8 @@ public class TomcatTestUtil { // Prevent instantiation } - public static Tomcat createTomcatServer(String contextPath, int port, Servlet servlet) { + public static Tomcat createTomcatServer(String contextPath, int port, Servlet servlet, + Filter... additionalFilters) { var tomcat = new Tomcat(); tomcat.setPort(port); @@ -43,15 +45,17 @@ public static Tomcat createTomcatServer(String contextPath, int port, Servlet se context.addChild(wrapper); context.addServletMappingDecoded("/*", "mcpServlet"); - var filterDef = new FilterDef(); - filterDef.setFilterClass(McpTestServletFilter.class.getName()); - filterDef.setFilterName(McpTestServletFilter.class.getSimpleName()); - context.addFilterDef(filterDef); + for (var filter : additionalFilters) { + var filterDef = new FilterDef(); + filterDef.setFilter(filter); + filterDef.setFilterName(McpTestRequestRecordingServletFilter.class.getSimpleName()); + context.addFilterDef(filterDef); - var filterMap = new FilterMap(); - filterMap.setFilterName(McpTestServletFilter.class.getSimpleName()); - filterMap.addURLPattern("/*"); - context.addFilterMap(filterMap); + var filterMap = new FilterMap(); + filterMap.setFilterName(McpTestRequestRecordingServletFilter.class.getSimpleName()); + filterMap.addURLPattern("/*"); + context.addFilterMap(filterMap); + } var connector = tomcat.getConnector(); connector.setAsyncTimeout(3000); diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java index 5ec272961..b67d34f6b 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java @@ -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.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> exceptionHandler = new AtomicReference<>(); + private final List 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 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 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 @@ private McpTransportSession createTransportSession() { : 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 @@ private Mono reconnect(McpTransportStream stream) { 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 Mono sendMessage(McpSchema.JSONRPCMessage message) { 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 static class Builder { private boolean openConnectionOnStartup = false; + private List 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 Builder openConnectionOnStartup(boolean openConnectionOnStartup) { 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. + *

+ * 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 version + * negotiation specification + * @see Protocol + * Version Header + */ + public Builder supportedProtocolVersions(List 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 Builder openConnectionOnStartup(boolean openConnectionOnStartup) { */ public WebClientStreamableHttpTransport build() { return new WebClientStreamableHttpTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - webClientBuilder, endpoint, resumableStreams, openConnectionOnStartup); + webClientBuilder, endpoint, resumableStreams, openConnectionOnStartup, supportedProtocolVersions); } } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java new file mode 100644 index 000000000..7627bd419 --- /dev/null +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java @@ -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 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 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(); + } + +} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpTestRequestRecordingExchangeFilterFunction.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpTestRequestRecordingExchangeFilterFunction.java new file mode 100644 index 000000000..5600795c1 --- /dev/null +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpTestRequestRecordingExchangeFilterFunction.java @@ -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 calls = new ArrayList<>(); + + @Override + public Mono filter(ServerRequest request, HandlerFunction next) { + Map 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 getCalls() { + return List.copyOf(calls); + } + + public record Call(Map headers, String body) { + + } + +} From 67f8eabb7a0ab70b43b5223768b3aaafd243c843 Mon Sep 17 00:00:00 2001 From: "He-Pin(kerr)" Date: Wed, 19 Nov 2025 17:50:44 +0800 Subject: [PATCH 16/83] WebClientStreamableHttpTransport: use Spring-5 compatible methods (#649) Signed-off-by: He-Pin Signed-off-by: Daniel Garnier-Moiroux --- .../WebClientStreamableHttpTransport.java | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java index b67d34f6b..6b1d6ba8a 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java @@ -308,7 +308,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { // The spec mentions only ACCEPTED, but the existing SDKs can return // 200 OK for notifications - if (response.statusCode().is2xxSuccessful()) { + if (is2xx(response)) { Optional contentType = response.headers().contentType(); long contentLength = response.headers().contentLength().orElse(-1); // Existing SDKs consume notifications with no response body nor @@ -392,14 +392,15 @@ private Flux extractError(ClientResponse response, Str } catch (IOException ex) { toPropagate = new McpTransportException("Sending request failed, " + e.getMessage(), e); - logger.debug("Received content together with {} HTTP code response: {}", response.statusCode(), body); + logger.debug("Received content together with {} HTTP code response: {}", response.rawStatusCode(), + body); } // Some implementations can return 400 when presented with a // session id that it doesn't know about, so we will // invalidate the session // https://github.com/modelcontextprotocol/typescript-sdk/issues/389 - if (responseException.getStatusCode().isSameCodeAs(HttpStatus.BAD_REQUEST)) { + if (isBadRequest(responseException)) { if (!sessionRepresentation.equals(MISSING_SESSION_ID)) { return Mono.error(new McpTransportSessionNotFoundException(sessionRepresentation, toPropagate)); } @@ -419,16 +420,8 @@ private Flux eventStream(McpTransportStream= 200 && response.rawStatusCode() < 300; + } + } From 86991c1e4951d13fbbc68961ad0b315313772b65 Mon Sep 17 00:00:00 2001 From: lance Date: Thu, 30 Oct 2025 22:57:58 +0800 Subject: [PATCH 17/83] fix the baseUrl is configured with a trailing slash Signed-off-by: lance Signed-off-by: Daniel Garnier-Moiroux --- ...HttpServletSseServerTransportProvider.java | 67 +++++++--- .../WebFluxSseServerTransportProvider.java | 29 +++-- .../WebMvcSseServerTransportProvider.java | 40 ++++-- ...WebMvcSseServerTransportProviderTests.java | 119 ++++++++++++++++++ 4 files changed, 221 insertions(+), 34 deletions(-) create mode 100644 mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java index 4739e231a..96cebb74a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java @@ -14,9 +14,9 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; -import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; @@ -69,7 +69,9 @@ @WebServlet(asyncSupported = true) public class HttpServletSseServerTransportProvider extends HttpServlet implements McpServerTransportProvider { - /** Logger for this class */ + /** + * Logger for this class + */ private static final Logger logger = LoggerFactory.getLogger(HttpServletSseServerTransportProvider.class); public static final String UTF_8 = "UTF-8"; @@ -78,38 +80,60 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement public static final String FAILED_TO_SEND_ERROR_RESPONSE = "Failed to send error response: {}"; - /** Default endpoint path for SSE connections */ + /** + * Default endpoint path for SSE connections + */ public static final String DEFAULT_SSE_ENDPOINT = "/sse"; - /** Event type for regular messages */ + /** + * Event type for regular messages + */ public static final String MESSAGE_EVENT_TYPE = "message"; - /** Event type for endpoint information */ + /** + * Event type for endpoint information + */ public static final String ENDPOINT_EVENT_TYPE = "endpoint"; + public static final String SESSION_ID = "sessionId"; + public static final String DEFAULT_BASE_URL = ""; - /** JSON mapper for serialization/deserialization */ + /** + * JSON mapper for serialization/deserialization + */ private final McpJsonMapper jsonMapper; - /** Base URL for the server transport */ + /** + * Base URL for the server transport + */ private final String baseUrl; - /** The endpoint path for handling client messages */ + /** + * The endpoint path for handling client messages + */ private final String messageEndpoint; - /** The endpoint path for handling SSE connections */ + /** + * The endpoint path for handling SSE connections + */ private final String sseEndpoint; - /** Map of active client sessions, keyed by session ID */ + /** + * Map of active client sessions, keyed by session ID + */ private final Map sessions = new ConcurrentHashMap<>(); private McpTransportContextExtractor contextExtractor; - /** Flag indicating if the transport is in the process of shutting down */ + /** + * Flag indicating if the transport is in the process of shutting down + */ private final AtomicBoolean isClosing = new AtomicBoolean(false); - /** Session factory for creating new sessions */ + /** + * Session factory for creating new sessions + */ private McpServerSession.Factory sessionFactory; /** @@ -243,7 +267,22 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) this.sessions.put(sessionId, session); // Send initial endpoint event - this.sendEvent(writer, ENDPOINT_EVENT_TYPE, this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId); + this.sendEvent(writer, ENDPOINT_EVENT_TYPE, buildEndpointUrl(sessionId)); + } + + /** + * Constructs the full message endpoint URL by combining the base URL, message path, + * and the required session_id query parameter. + * @param sessionId the unique session identifier + * @return the fully qualified endpoint URL as a string + */ + private String buildEndpointUrl(String sessionId) { + // for WebMVC compatibility + if (this.baseUrl.endsWith("/")) { + return this.baseUrl.substring(0, this.baseUrl.length() - 1) + this.messageEndpoint + "?sessionId=" + + sessionId; + } + return this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId; } /** @@ -434,8 +473,8 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { * Converts data from one type to another using the configured JsonMapper. * @param data The source data object to convert * @param typeRef The target type reference - * @return The converted object of type T * @param The target type + * @return The converted object of type T */ @Override public T unmarshalFrom(Object data, TypeRef typeRef) { diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java index 95355c0f2..0c80c5b8b 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java @@ -9,10 +9,9 @@ import java.util.List; import java.util.concurrent.ConcurrentHashMap; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; - -import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; @@ -22,7 +21,6 @@ import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.Exceptions; @@ -37,6 +35,7 @@ import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.util.UriComponentsBuilder; /** * Server-side implementation of the MCP (Model Context Protocol) HTTP transport using @@ -95,6 +94,8 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv */ public static final String DEFAULT_SSE_ENDPOINT = "/sse"; + public static final String SESSION_ID = "sessionId"; + public static final String DEFAULT_BASE_URL = ""; private final McpJsonMapper jsonMapper; @@ -224,6 +225,7 @@ public Mono notifyClients(String method, Object params) { // FIXME: This javadoc makes claims about using isClosing flag but it's not // actually // doing that. + /** * Initiates a graceful shutdown of all the sessions. This method ensures all active * sessions are properly closed and cleaned up. @@ -286,10 +288,8 @@ private Mono handleSseConnection(ServerRequest request) { // Send initial endpoint event logger.debug("Sending initial endpoint event to session: {}", sessionId); - sink.next(ServerSentEvent.builder() - .event(ENDPOINT_EVENT_TYPE) - .data(this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId) - .build()); + sink.next( + ServerSentEvent.builder().event(ENDPOINT_EVENT_TYPE).data(buildEndpointUrl(sessionId)).build()); sink.onCancel(() -> { logger.debug("Session {} cancelled", sessionId); sessions.remove(sessionId); @@ -297,6 +297,21 @@ private Mono handleSseConnection(ServerRequest request) { }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), ServerSentEvent.class); } + /** + * Constructs the full message endpoint URL by combining the base URL, message path, + * and the required session_id query parameter. + * @param sessionId the unique session identifier + * @return the fully qualified endpoint URL as a string + */ + private String buildEndpointUrl(String sessionId) { + // for WebMVC compatibility + return UriComponentsBuilder.fromUriString(this.baseUrl) + .path(this.messageEndpoint) + .queryParam(SESSION_ID, sessionId) + .build() + .toUriString(); + } + /** * Handles incoming JSON-RPC messages from clients. Deserializes the message and * processes it through the configured message handler. diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java index 0b71ddc1f..dfaee64b5 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java @@ -11,20 +11,18 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; - -import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; import io.modelcontextprotocol.spec.McpServerTransport; import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.ProtocolVersions; -import io.modelcontextprotocol.spec.McpServerSession; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; @@ -36,6 +34,7 @@ import org.springframework.web.servlet.function.ServerRequest; import org.springframework.web.servlet.function.ServerResponse; import org.springframework.web.servlet.function.ServerResponse.SseBuilder; +import org.springframework.web.util.UriComponentsBuilder; /** * Server-side implementation of the Model Context Protocol (MCP) transport layer using @@ -87,6 +86,8 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi */ public static final String ENDPOINT_EVENT_TYPE = "endpoint"; + public static final String SESSION_ID = "sessionId"; + /** * Default SSE endpoint path as specified by the MCP transport specification. */ @@ -275,9 +276,7 @@ private ServerResponse handleSseConnection(ServerRequest request) { this.sessions.put(sessionId, session); try { - sseBuilder.id(sessionId) - .event(ENDPOINT_EVENT_TYPE) - .data(this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId); + sseBuilder.id(sessionId).event(ENDPOINT_EVENT_TYPE).data(buildEndpointUrl(sessionId)); } catch (Exception e) { logger.error("Failed to send initial endpoint event: {}", e.getMessage()); @@ -292,6 +291,21 @@ private ServerResponse handleSseConnection(ServerRequest request) { } } + /** + * Constructs the full message endpoint URL by combining the base URL, message path, + * and the required session_id query parameter. + * @param sessionId the unique session identifier + * @return the fully qualified endpoint URL as a string + */ + private String buildEndpointUrl(String sessionId) { + // for WebMVC compatibility + return UriComponentsBuilder.fromUriString(this.baseUrl) + .path(this.messageEndpoint) + .queryParam(SESSION_ID, sessionId) + .build() + .toUriString(); + } + /** * Handles incoming JSON-RPC messages from clients. This method: *