diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java index 5d82881..7f6ccc1 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java @@ -146,6 +146,11 @@ public class McpAsyncClient { */ private final McpTransport transport; + /** + * Supported protocol versions. + */ + private List protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION); + /** * Create a new McpAsyncClient with the given transport and session request-response * timeout. @@ -257,6 +262,8 @@ public class McpAsyncClient { Assert.notNull(requestTimeout, "Request timeout must not be null"); Assert.notNull(clientInfo, "Client info must not be null"); + this.protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION); + this.clientInfo = clientInfo; this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities @@ -360,8 +367,11 @@ public class McpAsyncClient { * @return the initialize result. */ public Mono initialize() { + + String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1); + McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off - McpSchema.LATEST_PROTOCOL_VERSION, + latestVersion, this.clientCapabilities, this.clientInfo); // @formatter:on @@ -378,7 +388,7 @@ public class McpAsyncClient { initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(), initializeResult.instructions()); - if (!McpSchema.LATEST_PROTOCOL_VERSION.equals(initializeResult.protocolVersion())) { + if (!this.protocolVersions.contains(initializeResult.protocolVersion())) { return Mono.error(new McpError( "Unsupported protocol version from the server: " + initializeResult.protocolVersion())); } @@ -911,4 +921,13 @@ public class McpAsyncClient { return this.mcpSession.sendNotification(McpSchema.METHOD_LOGGING_SET_LEVEL, params); } + /** + * This method is package-private and used for test only. Should not be called by user + * code. + * @param protocolVersions the Client supported protocol versions. + */ + void setProtocolVersions(List protocolVersions) { + this.protocolVersions = protocolVersions; + } + } diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java index 7d8bb17..05be98d 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java @@ -67,7 +67,6 @@ class McpClientFeatures { /** * Create an instance and validate the arguments. - * @param clientInfo the client implementation information. * @param clientCapabilities the client capabilities. * @param roots the roots. * @param toolsChangeConsumers the tools change consumers. @@ -85,7 +84,6 @@ class McpClientFeatures { Function> samplingHandler) { Assert.notNull(clientInfo, "Client info must not be null"); - this.clientInfo = clientInfo; this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities : new McpSchema.ClientCapabilities(null, @@ -182,7 +180,6 @@ class McpClientFeatures { Function samplingHandler) { Assert.notNull(clientInfo, "Client info must not be null"); - this.clientInfo = clientInfo; this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities : new McpSchema.ClientCapabilities(null, diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java index 8d406a5..00971b7 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java @@ -122,12 +122,18 @@ public class McpAsyncServer { private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG; + /** + * Supported protocol versions. + */ + private List protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION); + /** * Create a new McpAsyncServer with the given transport and capabilities. * @param mcpTransport The transport layer implementation for MCP communication. * @param features The MCP server supported features. */ McpAsyncServer(ServerMcpTransport mcpTransport, McpServerFeatures.Async features) { + this.serverInfo = features.serverInfo(); this.serverCapabilities = features.serverCapabilities(); this.tools.addAll(features.tools()); @@ -210,6 +216,7 @@ public class McpAsyncServer { Map resources, List resourceTemplates, Map prompts, List>> rootsChangeConsumers) { + this.protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION); this.serverInfo = serverInfo; if (!Utils.isEmpty(tools)) { this.tools.addAll(McpServer.mapDeprecatedTools(tools)); @@ -299,12 +306,18 @@ public class McpAsyncServer { initializeRequest.protocolVersion(), initializeRequest.capabilities(), initializeRequest.clientInfo()); - if (!McpSchema.LATEST_PROTOCOL_VERSION.equals(initializeRequest.protocolVersion())) { - return Mono.error(new McpError( - "Unsupported protocol version from client: " + initializeRequest.protocolVersion())); + String serverProtocolVersion = this.protocolVersions.get(this.protocolVersions.size() - 1); + + if (this.protocolVersions.contains(initializeRequest.protocolVersion())) { + serverProtocolVersion = initializeRequest.protocolVersion(); + } + else { + logger.warn( + "Client requested unsupported protocol version: {}, so the server will sugggest the {} version instead", + initializeRequest.protocolVersion(), serverProtocolVersion); } - return Mono.just(new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, this.serverCapabilities, + return Mono.just(new McpSchema.InitializeResult(serverProtocolVersion, this.serverCapabilities, this.serverInfo, null)); }; } @@ -903,4 +916,13 @@ public class McpAsyncServer { CREATE_MESSAGE_RESULT_TYPE_REF); } + /** + * This method is package-private and used for test only. Should not be called by user + * code. + * @param protocolVersions the Client supported protocol versions. + */ + void setProtocolVersions(List protocolVersions) { + this.protocolVersions = protocolVersions; + } + } diff --git a/mcp/src/main/java/org/springframework/ai/mcp/util/Assert.java b/mcp/src/main/java/org/springframework/ai/mcp/util/Assert.java index 8d4ea23..0dc1fc1 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/util/Assert.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/util/Assert.java @@ -16,11 +16,13 @@ package org.springframework.ai.mcp.util; +import java.util.Collection; + import reactor.util.annotation.Nullable; /** * Assertion utility class that assists in validating arguments. - * + * * @author Christian Tzolov */ @@ -29,6 +31,18 @@ import reactor.util.annotation.Nullable; */ public final class Assert { + /** + * Assert that the collection is not {@code null} and not empty. + * @param collection the collection to check + * @param message the exception message to use if the assertion fails + * @throws IllegalArgumentException if the collection is {@code null} or empty + */ + public static void notEmpty(@Nullable Collection collection, String message) { + if (collection == null || collection.isEmpty()) { + throw new IllegalArgumentException(message); + } + } + /** * Assert that an object is not {@code null}. * diff --git a/mcp/src/test/java/org/springframework/ai/mcp/MockMcpTransport.java b/mcp/src/test/java/org/springframework/ai/mcp/MockMcpTransport.java index 20736b1..1622418 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/MockMcpTransport.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/MockMcpTransport.java @@ -32,7 +32,10 @@ import org.springframework.ai.mcp.spec.McpSchema.JSONRPCNotification; import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest; import org.springframework.ai.mcp.spec.ServerMcpTransport; -@SuppressWarnings("unused") +/** + * A mock implementation of the {@link ClientMcpTransport} and {@link ServerMcpTransport} + * interfaces. + */ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport { private final AtomicInteger inboundMessageCount = new AtomicInteger(0); @@ -91,6 +94,7 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport connected = false; outgoing.tryEmitComplete(); inbound.tryEmitComplete(); + // Wait for all subscribers to complete return Mono.empty(); }); } @@ -100,4 +104,4 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport return new ObjectMapper().convertValue(data, typeRef); } -} \ No newline at end of file +} diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java index 2922bf0..c5b0ec8 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java @@ -20,7 +20,6 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.function.Consumer; import java.util.function.Function; import com.fasterxml.jackson.core.JsonProcessingException; diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/McpClientProtocolVersionTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/McpClientProtocolVersionTests.java new file mode 100644 index 0000000..7a0be90 --- /dev/null +++ b/mcp/src/test/java/org/springframework/ai/mcp/client/McpClientProtocolVersionTests.java @@ -0,0 +1,170 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.client; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.ai.mcp.MockMcpTransport; +import org.springframework.ai.mcp.spec.McpError; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.InitializeResult; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for MCP protocol version negotiation and compatibility. + */ +class McpClientProtocolVersionTests { + + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + + private static final McpSchema.Implementation CLIENT_INFO = new McpSchema.Implementation("test-client", "1.0.0"); + + @Test + void shouldUseLatestVersionByDefault() { + MockMcpTransport transport = new MockMcpTransport(); + McpAsyncClient client = McpClient.async(transport) + .clientInfo(CLIENT_INFO) + .requestTimeout(REQUEST_TIMEOUT) + .build(); + + try { + Mono initializeResultMono = client.initialize(); + + StepVerifier.create(initializeResultMono).then(() -> { + McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + assertThat(request.params()).isInstanceOf(McpSchema.InitializeRequest.class); + McpSchema.InitializeRequest initRequest = (McpSchema.InitializeRequest) request.params(); + assertThat(initRequest.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION); + + transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), + new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, null, + new McpSchema.Implementation("test-server", "1.0.0"), null), + null)); + }).assertNext(result -> { + assertThat(result.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION); + }).verifyComplete(); + + } + finally { + // Ensure cleanup happens even if test fails + StepVerifier.create(client.closeGracefully()).verifyComplete(); + } + } + + @Test + void shouldNegotiateSpecificVersion() { + String oldVersion = "0.1.0"; + MockMcpTransport transport = new MockMcpTransport(); + McpAsyncClient client = McpClient.async(transport) + .clientInfo(CLIENT_INFO) + .requestTimeout(REQUEST_TIMEOUT) + .build(); + + client.setProtocolVersions(List.of(oldVersion, McpSchema.LATEST_PROTOCOL_VERSION)); + + try { + Mono initializeResultMono = client.initialize(); + + StepVerifier.create(initializeResultMono).then(() -> { + McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + assertThat(request.params()).isInstanceOf(McpSchema.InitializeRequest.class); + McpSchema.InitializeRequest initRequest = (McpSchema.InitializeRequest) request.params(); + assertThat(initRequest.protocolVersion()).isIn(List.of(oldVersion, McpSchema.LATEST_PROTOCOL_VERSION)); + + transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), + new McpSchema.InitializeResult(oldVersion, null, + new McpSchema.Implementation("test-server", "1.0.0"), null), + null)); + }).assertNext(result -> { + assertThat(result.protocolVersion()).isEqualTo(oldVersion); + }).verifyComplete(); + } + finally { + StepVerifier.create(client.closeGracefully()).verifyComplete(); + } + } + + @Test + void shouldFailForUnsupportedVersion() { + String unsupportedVersion = "999.999.999"; + MockMcpTransport transport = new MockMcpTransport(); + McpAsyncClient client = McpClient.async(transport) + .clientInfo(CLIENT_INFO) + .requestTimeout(REQUEST_TIMEOUT) + .build(); + + try { + Mono initializeResultMono = client.initialize(); + + StepVerifier.create(initializeResultMono).then(() -> { + McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + assertThat(request.params()).isInstanceOf(McpSchema.InitializeRequest.class); + + transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), + new McpSchema.InitializeResult(unsupportedVersion, null, + new McpSchema.Implementation("test-server", "1.0.0"), null), + null)); + }).expectError(McpError.class).verify(); + } + finally { + StepVerifier.create(client.closeGracefully()).verifyComplete(); + } + } + + @Test + void shouldUseHighestVersionWhenMultipleSupported() { + String oldVersion = "0.1.0"; + String middleVersion = "0.2.0"; + String latestVersion = McpSchema.LATEST_PROTOCOL_VERSION; + + MockMcpTransport transport = new MockMcpTransport(); + McpAsyncClient client = McpClient.async(transport) + .clientInfo(CLIENT_INFO) + .requestTimeout(REQUEST_TIMEOUT) + .build(); + + client.setProtocolVersions(List.of(oldVersion, middleVersion, latestVersion)); + + try { + Mono initializeResultMono = client.initialize(); + + StepVerifier.create(initializeResultMono).then(() -> { + McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + McpSchema.InitializeRequest initRequest = (McpSchema.InitializeRequest) request.params(); + assertThat(initRequest.protocolVersion()).isEqualTo(latestVersion); + + transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), + new McpSchema.InitializeResult(latestVersion, null, + new McpSchema.Implementation("test-server", "1.0.0"), null), + null)); + }).assertNext(result -> { + assertThat(result.protocolVersion()).isEqualTo(latestVersion); + }).verifyComplete(); + } + finally { + StepVerifier.create(client.closeGracefully()).verifyComplete(); + } + + } + +} diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/McpServerProtocolVersionTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/McpServerProtocolVersionTests.java new file mode 100644 index 0000000..f557921 --- /dev/null +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/McpServerProtocolVersionTests.java @@ -0,0 +1,132 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.server; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.mcp.MockMcpTransport; +import org.springframework.ai.mcp.spec.McpSchema; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for MCP server protocol version negotiation and compatibility. + */ +class McpServerProtocolVersionTests { + + private static final McpSchema.Implementation SERVER_INFO = new McpSchema.Implementation("test-server", "1.0.0"); + + private static final McpSchema.Implementation CLIENT_INFO = new McpSchema.Implementation("test-client", "1.0.0"); + + private McpSchema.JSONRPCRequest jsonRpcInitializeRequest(String requestId, String protocolVersion) { + return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, requestId, + new McpSchema.InitializeRequest(protocolVersion, null, CLIENT_INFO)); + } + + @Test + void shouldUseLatestVersionByDefault() { + MockMcpTransport transport = new MockMcpTransport(); + McpAsyncServer server = McpServer.async(transport).serverInfo(SERVER_INFO).build(); + + String requestId = UUID.randomUUID().toString(); + + transport.simulateIncomingMessage(jsonRpcInitializeRequest(requestId, McpSchema.LATEST_PROTOCOL_VERSION)); + + McpSchema.JSONRPCMessage response = transport.getLastSentMessage(); + assertThat(response).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse jsonResponse = (McpSchema.JSONRPCResponse) response; + assertThat(jsonResponse.id()).isEqualTo(requestId); + assertThat(jsonResponse.result()).isInstanceOf(McpSchema.InitializeResult.class); + McpSchema.InitializeResult result = (McpSchema.InitializeResult) jsonResponse.result(); + assertThat(result.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION); + + server.closeGracefully().subscribe(); + } + + @Test + void shouldNegotiateSpecificVersion() { + String oldVersion = "0.1.0"; + MockMcpTransport transport = new MockMcpTransport(); + McpAsyncServer server = McpServer.async(transport).serverInfo(SERVER_INFO).build(); + + server.setProtocolVersions(List.of(oldVersion, McpSchema.LATEST_PROTOCOL_VERSION)); + + String requestId = UUID.randomUUID().toString(); + + transport.simulateIncomingMessage(jsonRpcInitializeRequest(requestId, oldVersion)); + + McpSchema.JSONRPCMessage response = transport.getLastSentMessage(); + assertThat(response).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse jsonResponse = (McpSchema.JSONRPCResponse) response; + assertThat(jsonResponse.id()).isEqualTo(requestId); + assertThat(jsonResponse.result()).isInstanceOf(McpSchema.InitializeResult.class); + McpSchema.InitializeResult result = (McpSchema.InitializeResult) jsonResponse.result(); + assertThat(result.protocolVersion()).isEqualTo(oldVersion); + + server.closeGracefully().subscribe(); + } + + @Test + void shouldSuggestLatestVersionForUnsupportedVersion() { + String unsupportedVersion = "999.999.999"; + MockMcpTransport transport = new MockMcpTransport(); + McpAsyncServer server = McpServer.async(transport).serverInfo(SERVER_INFO).build(); + + String requestId = UUID.randomUUID().toString(); + + transport.simulateIncomingMessage(jsonRpcInitializeRequest(requestId, unsupportedVersion)); + + McpSchema.JSONRPCMessage response = transport.getLastSentMessage(); + assertThat(response).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse jsonResponse = (McpSchema.JSONRPCResponse) response; + assertThat(jsonResponse.id()).isEqualTo(requestId); + assertThat(jsonResponse.result()).isInstanceOf(McpSchema.InitializeResult.class); + McpSchema.InitializeResult result = (McpSchema.InitializeResult) jsonResponse.result(); + assertThat(result.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION); + + server.closeGracefully().subscribe(); + } + + @Test + void shouldUseHighestVersionWhenMultipleSupported() { + String oldVersion = "0.1.0"; + String middleVersion = "0.2.0"; + String latestVersion = McpSchema.LATEST_PROTOCOL_VERSION; + + MockMcpTransport transport = new MockMcpTransport(); + McpAsyncServer server = McpServer.async(transport).serverInfo(SERVER_INFO).build(); + + server.setProtocolVersions(List.of(oldVersion, middleVersion, latestVersion)); + + String requestId = UUID.randomUUID().toString(); + transport.simulateIncomingMessage(jsonRpcInitializeRequest(requestId, latestVersion)); + + McpSchema.JSONRPCMessage response = transport.getLastSentMessage(); + assertThat(response).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse jsonResponse = (McpSchema.JSONRPCResponse) response; + assertThat(jsonResponse.id()).isEqualTo(requestId); + assertThat(jsonResponse.result()).isInstanceOf(McpSchema.InitializeResult.class); + McpSchema.InitializeResult result = (McpSchema.InitializeResult) jsonResponse.result(); + assertThat(result.protocolVersion()).isEqualTo(latestVersion); + + server.closeGracefully().subscribe(); + } + +}