From 5f95511ee557868bcc418293fff2bf2beaeb355a Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 14 Feb 2025 13:26:14 +0100 Subject: [PATCH 001/393] Release version 0.7.0 Signed-off-by: Christian Tzolov --- mcp-bom/pom.xml | 2 +- mcp-spring/mcp-spring-webflux/pom.xml | 6 +++--- mcp-spring/mcp-spring-webmvc/pom.xml | 6 +++--- mcp-test/pom.xml | 4 ++-- mcp/pom.xml | 2 +- pom.xml | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index 6178bde..014d9ec 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.7.0-SNAPSHOT + 0.7.0 mcp-bom diff --git a/mcp-spring/mcp-spring-webflux/pom.xml b/mcp-spring/mcp-spring-webflux/pom.xml index ec93eea..72e0500 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.7.0-SNAPSHOT + 0.7.0 ../../pom.xml mcp-spring-webflux @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp - 0.7.0-SNAPSHOT + 0.7.0 io.modelcontextprotocol.sdk mcp-test - 0.7.0-SNAPSHOT + 0.7.0 test diff --git a/mcp-spring/mcp-spring-webmvc/pom.xml b/mcp-spring/mcp-spring-webmvc/pom.xml index fa5766a..c90bfef 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.7.0-SNAPSHOT + 0.7.0 ../../pom.xml mcp-spring-webmvc @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp - 0.7.0-SNAPSHOT + 0.7.0 io.modelcontextprotocol.sdk mcp-test - 0.7.0-SNAPSHOT + 0.7.0 test diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index e6b4170..b72b30b 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.7.0-SNAPSHOT + 0.7.0 mcp-test jar @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk mcp - 0.7.0-SNAPSHOT + 0.7.0 diff --git a/mcp/pom.xml b/mcp/pom.xml index 9890371..98740ba 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.7.0-SNAPSHOT + 0.7.0 mcp jar diff --git a/pom.xml b/pom.xml index d4aa71a..d772d52 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.7.0-SNAPSHOT + 0.7.0 pom https://github.com/modelcontextprotocol/java-sdk From 352ec2db095af02697387db9f428d8ba258703b0 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 14 Feb 2025 13:39:20 +0100 Subject: [PATCH 002/393] Next development version 0.8.0-SNAPSHOT Signed-off-by: Christian Tzolov --- mcp-bom/pom.xml | 2 +- mcp-spring/mcp-spring-webflux/pom.xml | 6 +++--- mcp-spring/mcp-spring-webmvc/pom.xml | 6 +++--- mcp-test/pom.xml | 4 ++-- mcp/pom.xml | 2 +- pom.xml | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index 6178bde..3b2ad42 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.7.0-SNAPSHOT + 0.8.0-SNAPSHOT mcp-bom diff --git a/mcp-spring/mcp-spring-webflux/pom.xml b/mcp-spring/mcp-spring-webflux/pom.xml index ec93eea..4d9f96e 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.7.0-SNAPSHOT + 0.8.0-SNAPSHOT ../../pom.xml mcp-spring-webflux @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp - 0.7.0-SNAPSHOT + 0.8.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-test - 0.7.0-SNAPSHOT + 0.8.0-SNAPSHOT test diff --git a/mcp-spring/mcp-spring-webmvc/pom.xml b/mcp-spring/mcp-spring-webmvc/pom.xml index fa5766a..0eebdd2 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.7.0-SNAPSHOT + 0.8.0-SNAPSHOT ../../pom.xml mcp-spring-webmvc @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp - 0.7.0-SNAPSHOT + 0.8.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-test - 0.7.0-SNAPSHOT + 0.8.0-SNAPSHOT test diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index e6b4170..717f031 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.7.0-SNAPSHOT + 0.8.0-SNAPSHOT mcp-test jar @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk mcp - 0.7.0-SNAPSHOT + 0.8.0-SNAPSHOT diff --git a/mcp/pom.xml b/mcp/pom.xml index 9890371..d07d079 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.7.0-SNAPSHOT + 0.8.0-SNAPSHOT mcp jar diff --git a/pom.xml b/pom.xml index d4aa71a..0ccda72 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.7.0-SNAPSHOT + 0.8.0-SNAPSHOT pom https://github.com/modelcontextprotocol/java-sdk From c69cc18eb6912b8eb1c4b17b9ff60bc49b5997b3 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 17 Feb 2025 12:53:24 +0100 Subject: [PATCH 003/393] docs: add Spring AI MCP documentation and improve README clarity - Add Spring AI MCP documentation section with links to client/server starters - Fix license link text Signed-off-by: Christian Tzolov --- README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7bd7593..2157b7d 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,26 @@ # MCP Java SDK [![Build Status](https://github.com/modelcontextprotocol/java-sdk/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/modelcontextprotocol/java-sdk/actions/workflows/continuous-integration.yml) -Set of projects that provide Java SDK integration for the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture). -It enables Java applications to interact with AI models and tools through a standardized interface, supporting both synchronous and asynchronous communication patterns. +A set of projects that provide Java SDK integration for the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture). +This SDK enables Java applications to interact with AI models and tools through a standardized interface, supporting both synchronous and asynchronous communication patterns. ## 📚 Reference Documentation -For comprehensive guides and API documentation, visit the [MCP Java SDK Reference Documentation](https://modelcontextprotocol.io/sdk/java/mcp-overview). +#### MCP Java SDK documentation +For comprehensive guides and SDK API documentation, visit the [MCP Java SDK Reference Documentation](https://modelcontextprotocol.io/sdk/java/mcp-overview). +#### Spring AI MCP documentation +[Spring AI MCP](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html) extends the MCP Java SDK with Spring Boot integration, providing both [client](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html) and [server](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html) starters. Bootstrap your AI applications with MCP support using [Spring Initializer](https://start.spring.io). ## Development -- Building from Source +### Building from Source ```bash mvn clean install ``` -- Running Tests +### Running Tests ```bash mvn test @@ -44,4 +47,4 @@ Contributions are welcome! Please: ## License -This project is licensed under the [MIT](LICENSE). +This project is licensed under the [MIT License](LICENSE). From dac5c545b020c98c7f4b161e86d72630fca8c095 Mon Sep 17 00:00:00 2001 From: "zeko.zhang" Date: Mon, 24 Feb 2025 18:09:37 +0800 Subject: [PATCH 004/393] Fix issue, SSE connection will terminate after 30 seconds (DEFAULT). --- .../server/transport/WebMvcSseServerTransport.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java index f2d1d3e..4a31a48 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java @@ -5,6 +5,7 @@ package io.modelcontextprotocol.server.transport; import java.io.IOException; +import java.time.Duration; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; @@ -228,7 +229,7 @@ public class WebMvcSseServerTransport implements ServerMcpTransport { logger.error("Failed to poll event from session queue: {}", e.getMessage()); sseBuilder.error(e); } - }); + }, Duration.ZERO); } catch (Exception e) { logger.error("Failed to send initial endpoint event to session {}: {}", sessionId, e.getMessage()); From bb6514e808ddfd2a746151ac955d2375db42a3f1 Mon Sep 17 00:00:00 2001 From: Dennis Kawurek Date: Sat, 22 Feb 2025 15:33:32 +0100 Subject: [PATCH 005/393] Remove closed sessions from list in WebMvcSseServerTransport --- .../server/transport/WebMvcSseServerTransport.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java index 4a31a48..00928ec 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java @@ -218,6 +218,14 @@ public class WebMvcSseServerTransport implements ServerMcpTransport { // Send initial endpoint event try { return ServerResponse.sse(sseBuilder -> { + sseBuilder.onComplete(() -> { + logger.debug("SSE connection completed for session: {}", sessionId); + sessions.remove(sessionId); + }); + sseBuilder.onTimeout(() -> { + logger.debug("SSE connection timed out for session: {}", sessionId); + sessions.remove(sessionId); + }); ClientSession session = new ClientSession(sessionId, sseBuilder); this.sessions.put(sessionId, session); From 7165fea5ec0cb9dd6f20f2b505ac3c1dcd397b26 Mon Sep 17 00:00:00 2001 From: JermaineHua Date: Mon, 24 Feb 2025 22:55:26 +0800 Subject: [PATCH 006/393] Fix some typos Signed-off-by: JermaineHua --- .../client/transport/WebFluxSseClientTransport.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java index 4ab5cdd..8ea65fd 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java @@ -303,7 +303,7 @@ public class WebFluxSseClientTransport implements ClientMcpTransport { } // @formatter:on /** - * Unmarshals data from a generic Object into the specified type using the configured + * Unmarshalls data from a generic Object into the specified type using the configured * ObjectMapper. * *

@@ -313,7 +313,7 @@ public class WebFluxSseClientTransport implements ClientMcpTransport { * @param the target type to convert the data into * @param data the source object to convert * @param typeRef the TypeReference describing the target type - * @return the unmarshaled object of type T + * @return the unmarshalled object of type T * @throws IllegalArgumentException if the conversion cannot be performed */ @Override From 2c500b9cdcab8f2f78971f03108783c69f1bdc9c Mon Sep 17 00:00:00 2001 From: Mariano Gonzalez Date: Wed, 26 Feb 2025 11:49:30 +0100 Subject: [PATCH 007/393] Resources and prompts cannot be added after server start (#18) * Resources and prompts cannot be added after server start #17 --- .../io/modelcontextprotocol/server/McpAsyncServer.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 5fbc676..8f615f2 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -138,18 +138,14 @@ public class McpAsyncServer { } // Add resources API handlers if provided - if (!Utils.isEmpty(this.resources)) { + if (this.serverCapabilities.resources() != null) { requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler()); requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler()); - } - - // Add resource templates API handlers if provided. - if (!Utils.isEmpty(this.resourceTemplates)) { requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler()); } // Add prompts API handlers if provider exists - if (!Utils.isEmpty(this.prompts)) { + if (this.serverCapabilities.prompts() != null) { requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler()); requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler()); } From d9c48186075fd9e9b76428d2ca74cc56ea51cd6b Mon Sep 17 00:00:00 2001 From: konczdev Date: Sat, 15 Feb 2025 15:12:02 +0100 Subject: [PATCH 008/393] Generate OSGi metadata --- mcp/pom.xml | 44 ++++++++++++++++++++++++++++++++++++++++++++ pom.xml | 1 + 2 files changed, 45 insertions(+) diff --git a/mcp/pom.xml b/mcp/pom.xml index d07d079..2bd3e9d 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -20,6 +20,50 @@ git@github.com/modelcontextprotocol/java-sdk.git + + + + biz.aQute.bnd + bnd-maven-plugin + ${bnd-maven-plugin.version} + + + bnd-process + + bnd-process + + + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + + diff --git a/pom.xml b/pom.xml index 0ccda72..9551c4d 100644 --- a/pom.xml +++ b/pom.xml @@ -91,6 +91,7 @@ 11.0.2 6.1.0 4.2.0 + 7.1.0 From 718042644aef609eca83e5141b599f9d187b07de Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:00:04 +0100 Subject: [PATCH 009/393] refactor(client): improve validation and remove server methods (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add client initialization and capability validation checks - New isInitialized() method to check client state - Validate server capabilities before tool/resource operations - Add clear error messages for common failure cases - Remove server-side notification methods from client: sendResourcesListChanged(), promptListChangedNotification() - Improve protocol version handling - Testing improvements and new initialization tests - Redesign MockMcpTransport internals and adapt tests - Correct McpError messages - clean unused imports Resolves #13 Signed-off-by: Christian Tzolov Signed-off-by: Dariusz Jędrzejczyk Co-authored-by: Dariusz Jędrzejczyk --- .../MockMcpTransport.java | 49 ++++--- .../client/AbstractMcpAsyncClientTests.java | 3 - .../client/AbstractMcpSyncClientTests.java | 3 - .../client/McpAsyncClient.java | 57 ++++++--- .../client/McpSyncClient.java | 17 --- .../server/McpAsyncServer.java | 4 + .../MockMcpTransport.java | 41 +++--- .../client/AbstractMcpAsyncClientTests.java | 3 - .../client/AbstractMcpSyncClientTests.java | 3 - .../McpAsyncClientResponseHandlerTests.java | 120 ++++++++++++++---- 10 files changed, 186 insertions(+), 114 deletions(-) diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java b/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java index 0604768..d4e48ea 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java @@ -4,7 +4,9 @@ package io.modelcontextprotocol; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; import java.util.function.Function; import com.fasterxml.jackson.core.type.TypeReference; @@ -14,47 +16,53 @@ import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.ServerMcpTransport; import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; -import reactor.core.scheduler.Schedulers; -@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); - - private final Sinks.Many outgoing = Sinks.many().multicast().onBackpressureBuffer(); - private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer(); - private final Flux outboundView = outgoing.asFlux().cache(1); + private final List sent = new ArrayList<>(); + + private final BiConsumer interceptor; + + public MockMcpTransport() { + this((t, msg) -> { + }); + } + + public MockMcpTransport(BiConsumer interceptor) { + this.interceptor = interceptor; + } public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) { if (inbound.tryEmitNext(message).isFailure()) { - throw new RuntimeException("Failed to emit message " + message); + throw new RuntimeException("Failed to process incoming message " + message); } - inboundMessageCount.incrementAndGet(); } @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (outgoing.tryEmitNext(message).isFailure()) { - return Mono.error(new RuntimeException("Can't emit outgoing message " + message)); - } + sent.add(message); + interceptor.accept(this, message); return Mono.empty(); } public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() { - return (JSONRPCRequest) outboundView.blockFirst(); + return (JSONRPCRequest) getLastSentMessage(); } - public McpSchema.JSONRPCNotification getLastSentMessageAsNotifiation() { - return (JSONRPCNotification) outboundView.blockFirst(); + public McpSchema.JSONRPCNotification getLastSentMessageAsNotification() { + return (JSONRPCNotification) getLastSentMessage(); } public McpSchema.JSONRPCMessage getLastSentMessage() { - return outboundView.blockFirst(); + return !sent.isEmpty() ? sent.get(sent.size() - 1) : null; } private volatile boolean connected = false; @@ -66,7 +74,6 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport } connected = true; return inbound.asFlux() - .publishOn(Schedulers.boundedElastic()) .flatMap(message -> Mono.just(message).transform(handler)) .doFinally(signal -> connected = false) .then(); @@ -76,8 +83,8 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport public Mono closeGracefully() { return Mono.defer(() -> { connected = false; - outgoing.tryEmitComplete(); inbound.tryEmitComplete(); + // Wait for all subscribers to complete return Mono.empty(); }); } @@ -87,4 +94,4 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport return new ObjectMapper().convertValue(data, typeRef); } -} \ No newline at end of file +} diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index a904a87..092d5af 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -274,9 +274,6 @@ public abstract class AbstractMcpAsyncClientTests { assertThatCode(() -> { client.initialize().block(); - // Trigger notifications - client.sendResourcesListChanged().block(); - client.promptListChangedNotification().block(); client.closeGracefully().block(); }).doesNotThrowAnyException(); } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index b1c58a6..796dd5a 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -258,9 +258,6 @@ public abstract class AbstractMcpSyncClientTests { assertThatCode(() -> { client.initialize(); - // Trigger notifications - client.sendResourcesListChanged(); - client.promptListChangedNotification(); client.close(); }).doesNotThrowAnyException(); } diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index a49ad7f..e1bf574 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -296,6 +296,14 @@ public class McpAsyncClient { return this.serverInfo; } + /** + * Check if the client-server connection is initialized. + * @return true if the client-server connection is initialized + */ + public boolean isInitialized() { + return this.serverCapabilities != null; + } + /** * Get the client capabilities that define the supported features and functionality. * @return The client capabilities @@ -456,6 +464,12 @@ public class McpAsyncClient { * (false/absent) */ public Mono callTool(McpSchema.CallToolRequest callToolRequest) { + if (!this.isInitialized()) { + return Mono.error(new McpError("Client must be initialized before calling tools")); + } + if (this.serverCapabilities.tools() == null) { + return Mono.error(new McpError("Server does not provide tools capability")); + } return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF); } @@ -477,6 +491,12 @@ public class McpAsyncClient { * Optional cursor for pagination if more tools are available */ public Mono listTools(String cursor) { + if (!this.isInitialized()) { + return Mono.error(new McpError("Client must be initialized before listing tools")); + } + if (this.serverCapabilities.tools() == null) { + return Mono.error(new McpError("Server does not provide tools capability")); + } return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), LIST_TOOLS_RESULT_TYPE_REF); } @@ -532,6 +552,12 @@ public class McpAsyncClient { * @return A Mono that completes with the list of resources result */ public Mono listResources(String cursor) { + if (!this.isInitialized()) { + return Mono.error(new McpError("Client must be initialized before listing resources")); + } + if (this.serverCapabilities.resources() == null) { + return Mono.error(new McpError("Server does not provide the resources capability")); + } return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor), LIST_RESOURCES_RESULT_TYPE_REF); } @@ -551,6 +577,12 @@ public class McpAsyncClient { * @return A Mono that completes with the resource content */ public Mono readResource(McpSchema.ReadResourceRequest readResourceRequest) { + if (!this.isInitialized()) { + return Mono.error(new McpError("Client must be initialized before reading resources")); + } + if (this.serverCapabilities.resources() == null) { + return Mono.error(new McpError("Server does not provide the resources capability")); + } return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF); } @@ -575,19 +607,16 @@ public class McpAsyncClient { * @return A Mono that completes with the list of resource templates result */ public Mono listResourceTemplates(String cursor) { + if (!this.isInitialized()) { + return Mono.error(new McpError("Client must be initialized before listing resource templates")); + } + if (this.serverCapabilities.resources() == null) { + return Mono.error(new McpError("Server does not provide the resources capability")); + } return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF); } - /** - * List Changed Notification. When the list of available resources changes, servers - * that declared the listChanged capability SHOULD send a notification. - * @return A Mono that completes when the notification is sent - */ - public Mono sendResourcesListChanged() { - return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED); - } - /** * Subscriptions. The protocol supports optional subscriptions to resource changes. * Clients can subscribe to specific resources and receive notifications when they @@ -660,16 +689,6 @@ public class McpAsyncClient { return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF); } - /** - * (Server) An optional notification from the server to the client, informing it that - * the list of prompts it offers has changed. This may be issued by servers without - * any previous subscription from the client. - * @return A Mono that completes when the notification is sent - */ - public Mono promptListChangedNotification() { - return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED); - } - private NotificationHandler asyncPromptsChangeNotificationHandler( List, Mono>> promptsChangeConsumers) { return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers) diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index 0178b4b..e5d964b 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -284,14 +284,6 @@ public class McpSyncClient implements AutoCloseable { return this.delegate.listResourceTemplates().block(); } - /** - * List Changed Notification. When the list of available resources changes, servers - * that declared the listChanged capability SHOULD send a notification: - */ - public void sendResourcesListChanged() { - this.delegate.sendResourcesListChanged().block(); - } - /** * Subscriptions. The protocol supports optional subscriptions to resource changes. * Clients can subscribe to specific resources and receive notifications when they @@ -329,15 +321,6 @@ public class McpSyncClient implements AutoCloseable { return this.delegate.getPrompt(getPromptRequest).block(); } - /** - * (Server) An optional notification from the server to the client, informing it that - * the list of prompts it offers has changed. This may be issued by servers without - * any previous subscription from the client. - */ - public void promptListChangedNotification() { - this.delegate.promptListChangedNotification().block(); - } - /** * Client can set the minimum logging level it wants to receive from the server. * @param loggingLevel the min logging level diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 8f615f2..7b69167 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -188,9 +188,13 @@ public class McpAsyncServer { initializeRequest.protocolVersion(), initializeRequest.capabilities(), initializeRequest.clientInfo()); + // The server MUST respond with the highest protocol version it supports if + // it does not support the requested (e.g. Client) version. String serverProtocolVersion = this.protocolVersions.get(this.protocolVersions.size() - 1); if (this.protocolVersions.contains(initializeRequest.protocolVersion())) { + // If the server supports the requested protocol version, it MUST respond + // with the same version. serverProtocolVersion = initializeRequest.protocolVersion(); } else { diff --git a/mcp/src/test/java/io/modelcontextprotocol/MockMcpTransport.java b/mcp/src/test/java/io/modelcontextprotocol/MockMcpTransport.java index 1679ab7..d4e48ea 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/MockMcpTransport.java +++ b/mcp/src/test/java/io/modelcontextprotocol/MockMcpTransport.java @@ -4,7 +4,9 @@ package io.modelcontextprotocol; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; import java.util.function.Function; import com.fasterxml.jackson.core.type.TypeReference; @@ -14,10 +16,8 @@ import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.ServerMcpTransport; import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; -import reactor.core.scheduler.Schedulers; /** * A mock implementation of the {@link ClientMcpTransport} and {@link ServerMcpTransport} @@ -25,39 +25,44 @@ import reactor.core.scheduler.Schedulers; */ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport { - private final AtomicInteger inboundMessageCount = new AtomicInteger(0); - - private final Sinks.Many outgoing = Sinks.many().multicast().onBackpressureBuffer(); - private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer(); - private final Flux outboundView = outgoing.asFlux().cache(1); + private final List sent = new ArrayList<>(); + + private final BiConsumer interceptor; + + public MockMcpTransport() { + this((t, msg) -> { + }); + } + + public MockMcpTransport(BiConsumer interceptor) { + this.interceptor = interceptor; + } public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) { if (inbound.tryEmitNext(message).isFailure()) { - throw new RuntimeException("Failed to emit message " + message); + throw new RuntimeException("Failed to process incoming message " + message); } - inboundMessageCount.incrementAndGet(); } @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { - if (outgoing.tryEmitNext(message).isFailure()) { - return Mono.error(new RuntimeException("Can't emit outgoing message " + message)); - } + sent.add(message); + interceptor.accept(this, message); return Mono.empty(); } public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() { - return (JSONRPCRequest) outboundView.blockFirst(); + return (JSONRPCRequest) getLastSentMessage(); } - public McpSchema.JSONRPCNotification getLastSentMessageAsNotifiation() { - return (JSONRPCNotification) outboundView.blockFirst(); + public McpSchema.JSONRPCNotification getLastSentMessageAsNotification() { + return (JSONRPCNotification) getLastSentMessage(); } public McpSchema.JSONRPCMessage getLastSentMessage() { - return outboundView.blockFirst(); + return !sent.isEmpty() ? sent.get(sent.size() - 1) : null; } private volatile boolean connected = false; @@ -69,7 +74,6 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport } connected = true; return inbound.asFlux() - .publishOn(Schedulers.boundedElastic()) .flatMap(message -> Mono.just(message).transform(handler)) .doFinally(signal -> connected = false) .then(); @@ -79,7 +83,6 @@ public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport public Mono closeGracefully() { return Mono.defer(() -> { connected = false; - outgoing.tryEmitComplete(); inbound.tryEmitComplete(); // Wait for all subscribers to complete return Mono.empty(); diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 974f9c3..d32b191 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -275,9 +275,6 @@ public abstract class AbstractMcpAsyncClientTests { assertThatCode(() -> { client.initialize().block(); - // Trigger notifications - client.sendResourcesListChanged().block(); - client.promptListChangedNotification().block(); client.closeGracefully().block(); }).doesNotThrowAnyException(); } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 0750ccd..cbad541 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -259,9 +259,6 @@ public abstract class AbstractMcpSyncClientTests { assertThatCode(() -> { client.initialize(); - // Trigger notifications - client.sendResourcesListChanged(); - client.promptListChangedNotification(); client.close(); }).doesNotThrowAnyException(); } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java index d11fa0f..b1e82b7 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java @@ -4,7 +4,6 @@ package io.modelcontextprotocol.client; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -17,19 +16,82 @@ import io.modelcontextprotocol.MockMcpTransport; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.spec.McpSchema.InitializeResult; import io.modelcontextprotocol.spec.McpSchema.Root; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import static io.modelcontextprotocol.spec.McpSchema.METHOD_INITIALIZE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.awaitility.Awaitility.await; class McpAsyncClientResponseHandlerTests { + private static final McpSchema.Implementation SERVER_INFO = new McpSchema.Implementation("test-server", "1.0.0"); + + private static final McpSchema.ServerCapabilities SERVER_CAPABILITIES = McpSchema.ServerCapabilities.builder() + .tools(true) + .resources(true, true) // Enable both resources and resource templates + .build(); + + private static MockMcpTransport initializationEnabledTransport() { + return initializationEnabledTransport(SERVER_CAPABILITIES, SERVER_INFO); + } + + private static MockMcpTransport initializationEnabledTransport(McpSchema.ServerCapabilities mockServerCapabilities, + McpSchema.Implementation mockServerInfo) { + McpSchema.InitializeResult mockInitResult = new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, + mockServerCapabilities, mockServerInfo, "Test instructions"); + + return new MockMcpTransport((t, message) -> { + if (message instanceof McpSchema.JSONRPCRequest r && METHOD_INITIALIZE.equals(r.method())) { + McpSchema.JSONRPCResponse initResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, + r.id(), mockInitResult, null); + t.simulateIncomingMessage(initResponse); + } + }); + } + + @Test + void testSuccessfulInitialization() { + McpSchema.Implementation serverInfo = new McpSchema.Implementation("mcp-test-server", "0.0.1"); + McpSchema.ServerCapabilities serverCapabilities = McpSchema.ServerCapabilities.builder() + .tools(false) + .resources(true, true) // Enable both resources and resource templates + .build(); + MockMcpTransport transport = initializationEnabledTransport(serverCapabilities, serverInfo); + McpAsyncClient asyncMcpClient = McpClient.async(transport).build(); + + // Verify client is not initialized initially + assertThat(asyncMcpClient.isInitialized()).isFalse(); + + // Start initialization with reactive handling + InitializeResult result = asyncMcpClient.initialize().block(); + + // Verify initialized notification was sent + McpSchema.JSONRPCMessage notificationMessage = transport.getLastSentMessage(); + assertThat(notificationMessage).isInstanceOf(McpSchema.JSONRPCNotification.class); + McpSchema.JSONRPCNotification notification = (McpSchema.JSONRPCNotification) notificationMessage; + assertThat(notification.method()).isEqualTo(McpSchema.METHOD_NOTIFICATION_INITIALIZED); + + // Verify initialization result + assertThat(result).isNotNull(); + assertThat(result.protocolVersion()).isEqualTo(McpSchema.LATEST_PROTOCOL_VERSION); + assertThat(result.capabilities()).isEqualTo(serverCapabilities); + assertThat(result.serverInfo()).isEqualTo(serverInfo); + assertThat(result.instructions()).isEqualTo("Test instructions"); + + // Verify client state after initialization + assertThat(asyncMcpClient.isInitialized()).isTrue(); + assertThat(asyncMcpClient.getServerCapabilities()).isEqualTo(serverCapabilities); + assertThat(asyncMcpClient.getServerInfo()).isEqualTo(serverInfo); + + asyncMcpClient.closeGracefully(); + } + @Test void testToolsChangeNotificationHandling() throws JsonProcessingException { - MockMcpTransport transport = new MockMcpTransport(); + MockMcpTransport transport = initializationEnabledTransport(); // Create a list to store received tools for verification List receivedTools = new ArrayList<>(); @@ -41,6 +103,8 @@ class McpAsyncClientResponseHandlerTests { // Create client with tools change consumer McpAsyncClient asyncMcpClient = McpClient.async(transport).toolsChangeConsumer(toolsChangeConsumer).build(); + assertThat(asyncMcpClient.initialize().block()).isNotNull(); + // Create a mock tools list that the server will return Map inputSchema = Map.of("type", "object", "properties", Map.of(), "required", List.of()); McpSchema.Tool mockTool = new McpSchema.Tool("test-tool", "Test Tool Description", @@ -61,23 +125,23 @@ class McpAsyncClientResponseHandlerTests { transport.simulateIncomingMessage(toolsListResponse); // Verify the consumer received the expected tools - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(receivedTools).hasSize(1); - assertThat(receivedTools.get(0).name()).isEqualTo("test-tool"); - assertThat(receivedTools.get(0).description()).isEqualTo("Test Tool Description"); - }); + assertThat(receivedTools).hasSize(1); + assertThat(receivedTools.get(0).name()).isEqualTo("test-tool"); + assertThat(receivedTools.get(0).description()).isEqualTo("Test Tool Description"); asyncMcpClient.closeGracefully(); } @Test void testRootsListRequestHandling() { - MockMcpTransport transport = new MockMcpTransport(); + MockMcpTransport transport = initializationEnabledTransport(); McpAsyncClient asyncMcpClient = McpClient.async(transport) .roots(new Root("file:///test/path", "test-root")) .build(); + assertThat(asyncMcpClient.initialize().block()).isNotNull(); + // Simulate incoming request McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_ROOTS_LIST, "test-id", null); @@ -98,7 +162,7 @@ class McpAsyncClientResponseHandlerTests { @Test void testResourcesChangeNotificationHandling() { - MockMcpTransport transport = new MockMcpTransport(); + MockMcpTransport transport = initializationEnabledTransport(); // Create a list to store received resources for verification List receivedResources = new ArrayList<>(); @@ -112,6 +176,8 @@ class McpAsyncClientResponseHandlerTests { .resourcesChangeConsumer(resourcesChangeConsumer) .build(); + assertThat(asyncMcpClient.initialize().block()).isNotNull(); + // Create a mock resources list that the server will return McpSchema.Resource mockResource = new McpSchema.Resource("test://resource", "Test Resource", "A test resource", "text/plain", null); @@ -132,19 +198,17 @@ class McpAsyncClientResponseHandlerTests { transport.simulateIncomingMessage(resourcesListResponse); // Verify the consumer received the expected resources - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(receivedResources).hasSize(1); - assertThat(receivedResources.get(0).uri()).isEqualTo("test://resource"); - assertThat(receivedResources.get(0).name()).isEqualTo("Test Resource"); - assertThat(receivedResources.get(0).description()).isEqualTo("A test resource"); - }); + assertThat(receivedResources).hasSize(1); + assertThat(receivedResources.get(0).uri()).isEqualTo("test://resource"); + assertThat(receivedResources.get(0).name()).isEqualTo("Test Resource"); + assertThat(receivedResources.get(0).description()).isEqualTo("A test resource"); asyncMcpClient.closeGracefully(); } @Test void testPromptsChangeNotificationHandling() { - MockMcpTransport transport = new MockMcpTransport(); + MockMcpTransport transport = initializationEnabledTransport(); // Create a list to store received prompts for verification List receivedPrompts = new ArrayList<>(); @@ -156,6 +220,8 @@ class McpAsyncClientResponseHandlerTests { // Create client with prompts change consumer McpAsyncClient asyncMcpClient = McpClient.async(transport).promptsChangeConsumer(promptsChangeConsumer).build(); + assertThat(asyncMcpClient.initialize().block()).isNotNull(); + // Create a mock prompts list that the server will return McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "Test Prompt Description", List.of(new McpSchema.PromptArgument("arg1", "Test argument", true))); @@ -175,20 +241,18 @@ class McpAsyncClientResponseHandlerTests { transport.simulateIncomingMessage(promptsListResponse); // Verify the consumer received the expected prompts - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(receivedPrompts).hasSize(1); - assertThat(receivedPrompts.get(0).name()).isEqualTo("test-prompt"); - assertThat(receivedPrompts.get(0).description()).isEqualTo("Test Prompt Description"); - assertThat(receivedPrompts.get(0).arguments()).hasSize(1); - assertThat(receivedPrompts.get(0).arguments().get(0).name()).isEqualTo("arg1"); - }); + assertThat(receivedPrompts).hasSize(1); + assertThat(receivedPrompts.get(0).name()).isEqualTo("test-prompt"); + assertThat(receivedPrompts.get(0).description()).isEqualTo("Test Prompt Description"); + assertThat(receivedPrompts.get(0).arguments()).hasSize(1); + assertThat(receivedPrompts.get(0).arguments().get(0).name()).isEqualTo("arg1"); asyncMcpClient.closeGracefully(); } @Test void testSamplingCreateMessageRequestHandling() { - MockMcpTransport transport = new MockMcpTransport(); + MockMcpTransport transport = initializationEnabledTransport(); // Create a test sampling handler that echoes back the input Function> samplingHandler = request -> { @@ -203,6 +267,8 @@ class McpAsyncClientResponseHandlerTests { .sampling(samplingHandler) .build(); + assertThat(asyncMcpClient.initialize().block()).isNotNull(); + // Create a mock create message request var messageRequest = new McpSchema.CreateMessageRequest( List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))), @@ -240,13 +306,15 @@ class McpAsyncClientResponseHandlerTests { @Test void testSamplingCreateMessageRequestHandlingWithoutCapability() { - MockMcpTransport transport = new MockMcpTransport(); + MockMcpTransport transport = initializationEnabledTransport(); // Create client without sampling capability McpAsyncClient asyncMcpClient = McpClient.async(transport) .capabilities(ClientCapabilities.builder().build()) // No sampling capability .build(); + assertThat(asyncMcpClient.initialize().block()).isNotNull(); + // Create a mock create message request var messageRequest = new McpSchema.CreateMessageRequest( List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))), From b2c62d50cb67e0caa82662f25f961cdb4a7e391e Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:13:25 +0100 Subject: [PATCH 010/393] Update README with Maven wrapper commands and test prerequisites (#23) Resolves #20 Signed-off-by: Christian Tzolov --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2157b7d..caa6bf0 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,15 @@ For comprehensive guides and SDK API documentation, visit the [MCP Java SDK Refe ### Building from Source ```bash -mvn clean install +./mvnw clean install -DskipTests ``` ### Running Tests +To run the tests you have to pre-install `Docker` and `npx`. + ```bash -mvn test +./mvnw test ``` ## Contributing From 1bee8d47442db483f1664689860a8d6161be803e Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Mon, 3 Mar 2025 13:57:41 +0100 Subject: [PATCH 011/393] refactor(mcp): remove redundant type field from Content implementations (#27) - The type field and associated methods were redundant in Content implementations (TextContent, ImageContent, EmbeddedResource) as the type information is already handled by Jackson's polymorphic type handling via @JsonSubTypes annotation. - Add default implementation that returns the appropriate type string based on the implementing class (text, image, or resource) - Added comprehensive unit tests for McpSchema to validate serialization/deserialization behavior of all schema components. - Add deserialization tests for all content types - Add json-unit-assertj for flexible JSON testing - ignores array order and extra array items, reducing test brittleness while maintaining functional validation. Resolves #26 Resolve https://github.com/spring-projects/spring-ai/issues/2350 Signed-off-by: Christian Tzolov --- mcp/pom.xml | 16 +- .../modelcontextprotocol/spec/McpSchema.java | 42 +- .../spec/McpSchemaTests.java | 592 ++++++++++++++++++ pom.xml | 2 + 4 files changed, 619 insertions(+), 33 deletions(-) create mode 100644 mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java diff --git a/mcp/pom.xml b/mcp/pom.xml index 2bd3e9d..2170ffe 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -11,7 +11,7 @@ mcp jar Java MCP SDK - Java SDK implementation of the Model Context Protocol, enabling seamless integration with language models and AI tools + Java SDK implementation of the Model Context Protocol, enabling seamless integration with language models and AI tools https://github.com/modelcontextprotocol/java-sdk @@ -51,7 +51,7 @@ - + org.apache.maven.plugins maven-jar-plugin @@ -158,12 +158,20 @@ test + + net.javacrumbs.json-unit + json-unit-assertj + ${json-unit-assertj.version} + test + + + jakarta.servlet jakarta.servlet-api ${jakarta.servlet.version} - provided + provided @@ -183,4 +191,4 @@ - + \ No newline at end of file diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index e721468..3ce2068 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -964,7 +964,18 @@ public final class McpSchema { @JsonSubTypes.Type(value = EmbeddedResource.class, name = "resource") }) public sealed interface Content permits TextContent, ImageContent, EmbeddedResource { - String type(); + default String type() { + if (this instanceof TextContent) { + return "text"; + } + else if (this instanceof ImageContent) { + return "image"; + } + else if (this instanceof EmbeddedResource) { + return "resource"; + } + throw new IllegalArgumentException("Unknown content type: " + this); + } } @@ -972,19 +983,10 @@ public final class McpSchema { public record TextContent( // @formatter:off @JsonProperty("audience") List audience, @JsonProperty("priority") Double priority, - @JsonProperty("type") String type, @JsonProperty("text") String text) implements Content { // @formatter:on - public TextContent { - type = "text"; - } - - public String type() { - return type; - } - public TextContent(String content) { - this(null, null, "text", content); + this(null, null, content); } } @@ -992,33 +994,15 @@ public final class McpSchema { public record ImageContent( // @formatter:off @JsonProperty("audience") List audience, @JsonProperty("priority") Double priority, - @JsonProperty("type") String type, @JsonProperty("data") String data, @JsonProperty("mimeType") String mimeType) implements Content { // @formatter:on - - public ImageContent { - type = "image"; - } - - public String type() { - return type; - } } @JsonInclude(JsonInclude.Include.NON_ABSENT) public record EmbeddedResource( // @formatter:off @JsonProperty("audience") List audience, @JsonProperty("priority") Double priority, - @JsonProperty("type") String type, @JsonProperty("resource") ResourceContents resource) implements Content { // @formatter:on - - public EmbeddedResource { - type = "resource"; - } - - public String type() { - return type; - } } // --------------------------- diff --git a/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java new file mode 100644 index 0000000..05e2ce2 --- /dev/null +++ b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -0,0 +1,592 @@ +/* +* Copyright 2025 - 2025 the original author or authors. +*/ +package io.modelcontextprotocol.spec; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import net.javacrumbs.jsonunit.core.Option; +import org.junit.jupiter.api.Test; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Christian Tzolov + */ +public class McpSchemaTests { + + ObjectMapper mapper = new ObjectMapper(); + + // Content Types Tests + + @Test + void testTextContent() throws Exception { + McpSchema.TextContent test = new McpSchema.TextContent("XXX"); + String value = mapper.writeValueAsString(test); + + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"type":"text","text":"XXX"}""")); + } + + @Test + void testTextContentDeserialization() throws Exception { + McpSchema.TextContent textContent = mapper.readValue(""" + {"type":"text","text":"XXX"}""", McpSchema.TextContent.class); + + assertThat(textContent).isNotNull(); + assertThat(textContent.type()).isEqualTo("text"); + assertThat(textContent.text()).isEqualTo("XXX"); + } + + @Test + void testContentDeserializationWrongType() throws Exception { + + assertThatThrownBy(() -> mapper.readValue(""" + {"type":"WRONG","text":"XXX"}""", McpSchema.TextContent.class)) + .isInstanceOf(InvalidTypeIdException.class) + .hasMessageContaining( + "Could not resolve type id 'WRONG' as a subtype of `io.modelcontextprotocol.spec.McpSchema$TextContent`: known type ids = [image, resource, text]"); + } + + @Test + void testImageContent() throws Exception { + McpSchema.ImageContent test = new McpSchema.ImageContent(null, null, "base64encodeddata", "image/png"); + String value = mapper.writeValueAsString(test); + + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"type":"image","data":"base64encodeddata","mimeType":"image/png"}""")); + } + + @Test + void testImageContentDeserialization() throws Exception { + McpSchema.ImageContent imageContent = mapper.readValue(""" + {"type":"image","data":"base64encodeddata","mimeType":"image/png"}""", McpSchema.ImageContent.class); + assertThat(imageContent).isNotNull(); + assertThat(imageContent.type()).isEqualTo("image"); + assertThat(imageContent.data()).isEqualTo("base64encodeddata"); + assertThat(imageContent.mimeType()).isEqualTo("image/png"); + } + + @Test + void testEmbeddedResource() throws Exception { + McpSchema.TextResourceContents resourceContents = new McpSchema.TextResourceContents("resource://test", + "text/plain", "Sample resource content"); + + McpSchema.EmbeddedResource test = new McpSchema.EmbeddedResource(null, null, resourceContents); + + String value = mapper.writeValueAsString(test); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"type":"resource","resource":{"uri":"resource://test","mimeType":"text/plain","text":"Sample resource content"}}""")); + } + + @Test + void testEmbeddedResourceDeserialization() throws Exception { + McpSchema.EmbeddedResource embeddedResource = mapper.readValue( + """ + {"type":"resource","resource":{"uri":"resource://test","mimeType":"text/plain","text":"Sample resource content"}}""", + McpSchema.EmbeddedResource.class); + assertThat(embeddedResource).isNotNull(); + assertThat(embeddedResource.type()).isEqualTo("resource"); + assertThat(embeddedResource.resource()).isNotNull(); + assertThat(embeddedResource.resource().uri()).isEqualTo("resource://test"); + assertThat(embeddedResource.resource().mimeType()).isEqualTo("text/plain"); + assertThat(((TextResourceContents) embeddedResource.resource()).text()).isEqualTo("Sample resource content"); + } + + @Test + void testEmbeddedResourceWithBlobContents() throws Exception { + McpSchema.BlobResourceContents resourceContents = new McpSchema.BlobResourceContents("resource://test", + "application/octet-stream", "base64encodedblob"); + + McpSchema.EmbeddedResource test = new McpSchema.EmbeddedResource(null, null, resourceContents); + + String value = mapper.writeValueAsString(test); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"type":"resource","resource":{"uri":"resource://test","mimeType":"application/octet-stream","blob":"base64encodedblob"}}""")); + } + + @Test + void testEmbeddedResourceWithBlobContentsDeserialization() throws Exception { + McpSchema.EmbeddedResource embeddedResource = mapper.readValue( + """ + {"type":"resource","resource":{"uri":"resource://test","mimeType":"application/octet-stream","blob":"base64encodedblob"}}""", + McpSchema.EmbeddedResource.class); + assertThat(embeddedResource).isNotNull(); + assertThat(embeddedResource.type()).isEqualTo("resource"); + assertThat(embeddedResource.resource()).isNotNull(); + assertThat(embeddedResource.resource().uri()).isEqualTo("resource://test"); + assertThat(embeddedResource.resource().mimeType()).isEqualTo("application/octet-stream"); + assertThat(((McpSchema.BlobResourceContents) embeddedResource.resource()).blob()) + .isEqualTo("base64encodedblob"); + } + + // JSON-RPC Message Types Tests + + @Test + void testJSONRPCRequest() throws Exception { + Map params = new HashMap<>(); + params.put("key", "value"); + + McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method_name", 1, + params); + + String value = mapper.writeValueAsString(request); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"jsonrpc":"2.0","method":"method_name","id":1,"params":{"key":"value"}}""")); + } + + @Test + void testJSONRPCNotification() throws Exception { + Map params = new HashMap<>(); + params.put("key", "value"); + + McpSchema.JSONRPCNotification notification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, + "notification_method", params); + + String value = mapper.writeValueAsString(notification); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"jsonrpc":"2.0","method":"notification_method","params":{"key":"value"}}""")); + } + + @Test + void testJSONRPCResponse() throws Exception { + Map result = new HashMap<>(); + result.put("result_key", "result_value"); + + McpSchema.JSONRPCResponse response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, 1, result, null); + + String value = mapper.writeValueAsString(response); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"jsonrpc":"2.0","id":1,"result":{"result_key":"result_value"}}""")); + } + + @Test + void testJSONRPCResponseWithError() throws Exception { + McpSchema.JSONRPCResponse.JSONRPCError error = new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INVALID_REQUEST, "Invalid request", null); + + McpSchema.JSONRPCResponse response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, 1, null, error); + + String value = mapper.writeValueAsString(response); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid request"}}""")); + } + + // Initialization Tests + + @Test + void testInitializeRequest() throws Exception { + McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder() + .roots(true) + .sampling() + .build(); + + McpSchema.Implementation clientInfo = new McpSchema.Implementation("test-client", "1.0.0"); + + McpSchema.InitializeRequest request = new McpSchema.InitializeRequest("2024-11-05", capabilities, clientInfo); + + String value = mapper.writeValueAsString(request); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"protocolVersion":"2024-11-05","capabilities":{"roots":{"listChanged":true},"sampling":{}},"clientInfo":{"name":"test-client","version":"1.0.0"}}""")); + } + + @Test + void testInitializeResult() throws Exception { + McpSchema.ServerCapabilities capabilities = McpSchema.ServerCapabilities.builder() + .logging() + .prompts(true) + .resources(true, true) + .tools(true) + .build(); + + McpSchema.Implementation serverInfo = new McpSchema.Implementation("test-server", "1.0.0"); + + McpSchema.InitializeResult result = new McpSchema.InitializeResult("2024-11-05", capabilities, serverInfo, + "Server initialized successfully"); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"protocolVersion":"2024-11-05","capabilities":{"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":true,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"test-server","version":"1.0.0"},"instructions":"Server initialized successfully"}""")); + } + + // Resource Tests + + @Test + void testResource() throws Exception { + McpSchema.Annotations annotations = new McpSchema.Annotations( + Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.8); + + McpSchema.Resource resource = new McpSchema.Resource("resource://test", "Test Resource", "A test resource", + "text/plain", annotations); + + String value = mapper.writeValueAsString(resource); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"uri":"resource://test","name":"Test Resource","description":"A test resource","mimeType":"text/plain","annotations":{"audience":["user","assistant"],"priority":0.8}}""")); + } + + @Test + void testResourceTemplate() throws Exception { + McpSchema.Annotations annotations = new McpSchema.Annotations(Arrays.asList(McpSchema.Role.USER), 0.5); + + McpSchema.ResourceTemplate template = new McpSchema.ResourceTemplate("resource://{param}/test", "Test Template", + "A test resource template", "text/plain", annotations); + + String value = mapper.writeValueAsString(template); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"uriTemplate":"resource://{param}/test","name":"Test Template","description":"A test resource template","mimeType":"text/plain","annotations":{"audience":["user"],"priority":0.5}}""")); + } + + @Test + void testListResourcesResult() throws Exception { + McpSchema.Resource resource1 = new McpSchema.Resource("resource://test1", "Test Resource 1", + "First test resource", "text/plain", null); + + McpSchema.Resource resource2 = new McpSchema.Resource("resource://test2", "Test Resource 2", + "Second test resource", "application/json", null); + + McpSchema.ListResourcesResult result = new McpSchema.ListResourcesResult(Arrays.asList(resource1, resource2), + "next-cursor"); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"resources":[{"uri":"resource://test1","name":"Test Resource 1","description":"First test resource","mimeType":"text/plain"},{"uri":"resource://test2","name":"Test Resource 2","description":"Second test resource","mimeType":"application/json"}],"nextCursor":"next-cursor"}""")); + } + + @Test + void testListResourceTemplatesResult() throws Exception { + McpSchema.ResourceTemplate template1 = new McpSchema.ResourceTemplate("resource://{param}/test1", + "Test Template 1", "First test template", "text/plain", null); + + McpSchema.ResourceTemplate template2 = new McpSchema.ResourceTemplate("resource://{param}/test2", + "Test Template 2", "Second test template", "application/json", null); + + McpSchema.ListResourceTemplatesResult result = new McpSchema.ListResourceTemplatesResult( + Arrays.asList(template1, template2), "next-cursor"); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"resourceTemplates":[{"uriTemplate":"resource://{param}/test1","name":"Test Template 1","description":"First test template","mimeType":"text/plain"},{"uriTemplate":"resource://{param}/test2","name":"Test Template 2","description":"Second test template","mimeType":"application/json"}],"nextCursor":"next-cursor"}""")); + } + + @Test + void testReadResourceRequest() throws Exception { + McpSchema.ReadResourceRequest request = new McpSchema.ReadResourceRequest("resource://test"); + + String value = mapper.writeValueAsString(request); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"uri":"resource://test"}""")); + } + + @Test + void testReadResourceResult() throws Exception { + McpSchema.TextResourceContents contents1 = new McpSchema.TextResourceContents("resource://test1", "text/plain", + "Sample text content"); + + McpSchema.BlobResourceContents contents2 = new McpSchema.BlobResourceContents("resource://test2", + "application/octet-stream", "base64encodedblob"); + + McpSchema.ReadResourceResult result = new McpSchema.ReadResourceResult(Arrays.asList(contents1, contents2)); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"contents":[{"uri":"resource://test1","mimeType":"text/plain","text":"Sample text content"},{"uri":"resource://test2","mimeType":"application/octet-stream","blob":"base64encodedblob"}]}""")); + } + + // Prompt Tests + + @Test + void testPrompt() throws Exception { + McpSchema.PromptArgument arg1 = new McpSchema.PromptArgument("arg1", "First argument", true); + + McpSchema.PromptArgument arg2 = new McpSchema.PromptArgument("arg2", "Second argument", false); + + McpSchema.Prompt prompt = new McpSchema.Prompt("test-prompt", "A test prompt", Arrays.asList(arg1, arg2)); + + String value = mapper.writeValueAsString(prompt); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"name":"test-prompt","description":"A test prompt","arguments":[{"name":"arg1","description":"First argument","required":true},{"name":"arg2","description":"Second argument","required":false}]}""")); + } + + @Test + void testPromptMessage() throws Exception { + McpSchema.TextContent content = new McpSchema.TextContent("Hello, world!"); + + McpSchema.PromptMessage message = new McpSchema.PromptMessage(McpSchema.Role.USER, content); + + String value = mapper.writeValueAsString(message); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"role":"user","content":{"type":"text","text":"Hello, world!"}}""")); + } + + @Test + void testListPromptsResult() throws Exception { + McpSchema.PromptArgument arg = new McpSchema.PromptArgument("arg", "An argument", true); + + McpSchema.Prompt prompt1 = new McpSchema.Prompt("prompt1", "First prompt", Collections.singletonList(arg)); + + McpSchema.Prompt prompt2 = new McpSchema.Prompt("prompt2", "Second prompt", Collections.emptyList()); + + McpSchema.ListPromptsResult result = new McpSchema.ListPromptsResult(Arrays.asList(prompt1, prompt2), + "next-cursor"); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"prompts":[{"name":"prompt1","description":"First prompt","arguments":[{"name":"arg","description":"An argument","required":true}]},{"name":"prompt2","description":"Second prompt","arguments":[]}],"nextCursor":"next-cursor"}""")); + } + + @Test + void testGetPromptRequest() throws Exception { + Map arguments = new HashMap<>(); + arguments.put("arg1", "value1"); + arguments.put("arg2", 42); + + McpSchema.GetPromptRequest request = new McpSchema.GetPromptRequest("test-prompt", arguments); + + assertThat(mapper.readValue(""" + {"name":"test-prompt","arguments":{"arg1":"value1","arg2":42}}""", McpSchema.GetPromptRequest.class)) + .isEqualTo(request); + } + + @Test + void testGetPromptResult() throws Exception { + McpSchema.TextContent content1 = new McpSchema.TextContent("System message"); + McpSchema.TextContent content2 = new McpSchema.TextContent("User message"); + + McpSchema.PromptMessage message1 = new McpSchema.PromptMessage(McpSchema.Role.ASSISTANT, content1); + + McpSchema.PromptMessage message2 = new McpSchema.PromptMessage(McpSchema.Role.USER, content2); + + McpSchema.GetPromptResult result = new McpSchema.GetPromptResult("A test prompt result", + Arrays.asList(message1, message2)); + + String value = mapper.writeValueAsString(result); + + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"description":"A test prompt result","messages":[{"role":"assistant","content":{"type":"text","text":"System message"}},{"role":"user","content":{"type":"text","text":"User message"}}]}""")); + } + + // Tool Tests + + @Test + void testTool() throws Exception { + String schemaJson = """ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": ["name"] + } + """; + + McpSchema.Tool tool = new McpSchema.Tool("test-tool", "A test tool", schemaJson); + + String value = mapper.writeValueAsString(tool); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"name":"test-tool","description":"A test tool","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"number"}},"required":["name"]}}""")); + } + + @Test + void testCallToolRequest() throws Exception { + Map arguments = new HashMap<>(); + arguments.put("name", "test"); + arguments.put("value", 42); + + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("test-tool", arguments); + + String value = mapper.writeValueAsString(request); + + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"name":"test-tool","arguments":{"name":"test","value":42}}""")); + } + + @Test + void testCallToolResult() throws Exception { + McpSchema.TextContent content = new McpSchema.TextContent("Tool execution result"); + + McpSchema.CallToolResult result = new McpSchema.CallToolResult(Collections.singletonList(content), false); + + String value = mapper.writeValueAsString(result); + + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"content":[{"type":"text","text":"Tool execution result"}],"isError":false}""")); + } + + // Sampling Tests + + @Test + void testCreateMessageRequest() throws Exception { + McpSchema.TextContent content = new McpSchema.TextContent("User message"); + + McpSchema.SamplingMessage message = new McpSchema.SamplingMessage(McpSchema.Role.USER, content); + + McpSchema.ModelHint hint = new McpSchema.ModelHint("gpt-4"); + + McpSchema.ModelPreferences preferences = new McpSchema.ModelPreferences(Collections.singletonList(hint), 0.3, + 0.7, 0.9); + + Map metadata = new HashMap<>(); + metadata.put("session", "test-session"); + + McpSchema.CreateMessageRequest request = new McpSchema.CreateMessageRequest(Collections.singletonList(message), + preferences, "You are a helpful assistant", + McpSchema.CreateMessageRequest.ContextInclusionStrategy.THIS_SERVER, 0.7, 1000, + Arrays.asList("STOP", "END"), metadata); + + String value = mapper.writeValueAsString(request); + + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"messages":[{"role":"user","content":{"type":"text","text":"User message"}}],"modelPreferences":{"hints":[{"name":"gpt-4"}],"costPriority":0.3,"speedPriority":0.7,"intelligencePriority":0.9},"systemPrompt":"You are a helpful assistant","includeContext":"this_server","temperature":0.7,"maxTokens":1000,"stopSequences":["STOP","END"],"metadata":{"session":"test-session"}}""")); + } + + @Test + void testCreateMessageResult() throws Exception { + McpSchema.TextContent content = new McpSchema.TextContent("Assistant response"); + + McpSchema.CreateMessageResult result = new McpSchema.CreateMessageResult(McpSchema.Role.ASSISTANT, content, + "gpt-4", McpSchema.CreateMessageResult.StopReason.END_TURN); + + String value = mapper.writeValueAsString(result); + + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"role":"assistant","content":{"type":"text","text":"Assistant response"},"model":"gpt-4","stopReason":"end_turn"}""")); + } + + // Roots Tests + + @Test + void testRoot() throws Exception { + McpSchema.Root root = new McpSchema.Root("file:///path/to/root", "Test Root"); + + String value = mapper.writeValueAsString(root); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"uri":"file:///path/to/root","name":"Test Root"}""")); + } + + @Test + void testListRootsResult() throws Exception { + McpSchema.Root root1 = new McpSchema.Root("file:///path/to/root1", "First Root"); + + McpSchema.Root root2 = new McpSchema.Root("file:///path/to/root2", "Second Root"); + + McpSchema.ListRootsResult result = new McpSchema.ListRootsResult(Arrays.asList(root1, root2)); + + String value = mapper.writeValueAsString(result); + + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"roots":[{"uri":"file:///path/to/root1","name":"First Root"},{"uri":"file:///path/to/root2","name":"Second Root"}]}""")); + + } + +} diff --git a/pom.xml b/pom.xml index 9551c4d..893e5eb 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,8 @@ 6.1.0 4.2.0 7.1.0 + 4.1.0 + From 4eff00c8da01fffc830e166fa04d7b2a6463ff1c Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Mon, 10 Mar 2025 12:36:54 +0100 Subject: [PATCH 012/393] feat(mcp): relax MCP Schema JSON deserialization constraints (#37) * feat(mcp): relax MCP Schema JSON deserialization constraints - Add @JsonIgnoreProperties(ignoreUnknown = true) annotation to all record classes in McpSchema to make JSON deserialization more robust by ignoring unknown properties. - Make the JsonSchema record public Resolves #36 This improves compatibility with third-party implementations like Cursor that are not MCP Schema compliant. Signed-off-by: Christian Tzolov --- .../modelcontextprotocol/spec/McpSchema.java | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 3ce2068..2f55119 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -176,6 +176,7 @@ public final class McpSchema { } @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record JSONRPCRequest( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("method") String method, @@ -184,6 +185,7 @@ public final class McpSchema { } // @formatter:on @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record JSONRPCNotification( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("method") String method, @@ -191,6 +193,7 @@ public final class McpSchema { } // @formatter:on @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record JSONRPCResponse( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("id") Object id, @@ -198,6 +201,7 @@ public final class McpSchema { @JsonProperty("error") JSONRPCError error) implements JSONRPCMessage { @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record JSONRPCError( @JsonProperty("code") int code, @JsonProperty("message") String message, @@ -209,6 +213,7 @@ public final class McpSchema { // Initialization // --------------------------- @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record InitializeRequest( // @formatter:off @JsonProperty("protocolVersion") String protocolVersion, @JsonProperty("capabilities") ClientCapabilities capabilities, @@ -239,6 +244,7 @@ public final class McpSchema { * */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ClientCapabilities( // @formatter:off @JsonProperty("experimental") Map experimental, @JsonProperty("roots") RootCapabilities roots, @@ -253,7 +259,8 @@ public final class McpSchema { * @param listChanged Whether the client would send notification about roots * has changed since the last time the server checked. */ - @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record RootCapabilities( @JsonProperty("listChanged") Boolean listChanged) { } @@ -303,6 +310,7 @@ public final class McpSchema { }// @formatter:on @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ServerCapabilities( // @formatter:off @JsonProperty("experimental") Map experimental, @JsonProperty("logging") LoggingCapabilities logging, @@ -375,6 +383,7 @@ public final class McpSchema { } // @formatter:on @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Implementation(// @formatter:off @JsonProperty("name") String name, @JsonProperty("version") String version) { @@ -413,6 +422,7 @@ public final class McpSchema { * optional. It is a number between 0 and 1. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Annotations( // @formatter:off @JsonProperty("audience") List audience, @JsonProperty("priority") Double priority) { @@ -458,6 +468,7 @@ public final class McpSchema { * @see RFC 6570 */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ResourceTemplate( // @formatter:off @JsonProperty("uriTemplate") String uriTemplate, @JsonProperty("name") String name, @@ -481,6 +492,7 @@ public final class McpSchema { } // @formatter:on @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ReadResourceRequest( // @formatter:off @JsonProperty("uri") String uri){ } // @formatter:on @@ -499,11 +511,13 @@ public final class McpSchema { * it is up to the server how to interpret it. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record SubscribeRequest( // @formatter:off @JsonProperty("uri") String uri){ } // @formatter:on @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record UnsubscribeRequest( // @formatter:off @JsonProperty("uri") String uri){ } // @formatter:on @@ -574,6 +588,7 @@ public final class McpSchema { * @param arguments A list of arguments to use for templating the prompt. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Prompt( // @formatter:off @JsonProperty("name") String name, @JsonProperty("description") String description, @@ -588,6 +603,7 @@ public final class McpSchema { * @param required Whether this argument must be provided. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record PromptArgument( // @formatter:off @JsonProperty("name") String name, @JsonProperty("description") String description, @@ -604,6 +620,7 @@ public final class McpSchema { * @param content The content of the message of type {@link Content}. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record PromptMessage( // @formatter:off @JsonProperty("role") Role role, @JsonProperty("content") Content content) { @@ -630,6 +647,7 @@ public final class McpSchema { * @param arguments Arguments to use for templating the prompt. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record GetPromptRequest(// @formatter:off @JsonProperty("name") String name, @JsonProperty("arguments") Map arguments) implements Request { @@ -667,7 +685,7 @@ public final class McpSchema { @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - record JsonSchema( // @formatter:off + public record JsonSchema( // @formatter:off @JsonProperty("type") String type, @JsonProperty("properties") Map properties, @JsonProperty("required") List required, @@ -688,6 +706,7 @@ public final class McpSchema { * arguments before sending them to the server. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Tool( // @formatter:off @JsonProperty("name") String name, @JsonProperty("description") String description, @@ -742,6 +761,7 @@ public final class McpSchema { // Sampling Interfaces // --------------------------- @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ModelPreferences(// @formatter:off @JsonProperty("hints") List hints, @JsonProperty("costPriority") Double costPriority, @@ -750,10 +770,12 @@ public final class McpSchema { } // @formatter:on @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ModelHint(@JsonProperty("name") String name) { } @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record SamplingMessage(// @formatter:off @JsonProperty("role") Role role, @JsonProperty("content") Content content) { @@ -761,6 +783,7 @@ public final class McpSchema { // Sampling and Message Creation @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record CreateMessageRequest(// @formatter:off @JsonProperty("messages") List messages, @JsonProperty("modelPreferences") ModelPreferences modelPreferences, @@ -837,6 +860,7 @@ public final class McpSchema { // Pagination Interfaces // --------------------------- @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record PaginatedRequest(@JsonProperty("cursor") String cursor) { } @@ -848,6 +872,7 @@ public final class McpSchema { // --------------------------- // Progress and Logging // --------------------------- + @JsonIgnoreProperties(ignoreUnknown = true) public record ProgressNotification(// @formatter:off @JsonProperty("progressToken") String progressToken, @JsonProperty("progress") double progress, @@ -864,6 +889,7 @@ public final class McpSchema { * @param logger The logger that generated the message. * @param data JSON-serializable logging data. */ + @JsonIgnoreProperties(ignoreUnknown = true) public record LoggingMessageNotification(// @formatter:off @JsonProperty("level") LoggingLevel level, @JsonProperty("logger") String logger, @@ -980,6 +1006,7 @@ public final class McpSchema { } @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record TextContent( // @formatter:off @JsonProperty("audience") List audience, @JsonProperty("priority") Double priority, @@ -991,6 +1018,7 @@ public final class McpSchema { } @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ImageContent( // @formatter:off @JsonProperty("audience") List audience, @JsonProperty("priority") Double priority, @@ -999,6 +1027,7 @@ public final class McpSchema { } @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record EmbeddedResource( // @formatter:off @JsonProperty("audience") List audience, @JsonProperty("priority") Double priority, @@ -1019,6 +1048,7 @@ public final class McpSchema { * for referencing the root in other parts of the application. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Root( // @formatter:off @JsonProperty("uri") String uri, @JsonProperty("name") String name) { From 109aab20e8c86edea5095c51647401f26dc6a861 Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Thu, 13 Mar 2025 15:13:50 +0100 Subject: [PATCH 013/393] feat(client): Improve initialization state handling in McpAsyncClient (#39) - Add proper initialization state tracking using AtomicBoolean and Sinks - Implement timeout handling for requests requiring initialization - Ensure all client methods verify initialization state before proceeding - Fix rootsListChangedNotification to check initialization state - Improve error messages for uninitialized client operations - improve JavaDoc - Add tests to verify proper error handling for uninitialized clients - Replace hardcoded timeout constants with configurable getTimeoutDuration() method - Remove automatic initialization in setUp methods to allow explicit testing Signed-off-by: Christian Tzolov --- .../client/WebFluxSseMcpAsyncClientTests.java | 7 + .../client/WebFluxSseMcpSyncClientTests.java | 7 + .../client/AbstractMcpAsyncClientTests.java | 98 +++- .../client/AbstractMcpSyncClientTests.java | 85 +++- .../client/McpAsyncClient.java | 461 ++++++++++-------- .../client/AbstractMcpAsyncClientTests.java | 98 +++- .../client/AbstractMcpSyncClientTests.java | 85 +++- .../client/ServletSseMcpAsyncClientTests.java | 7 + .../client/ServletSseMcpSyncClientTests.java | 7 + 9 files changed, 613 insertions(+), 242 deletions(-) diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java index 021ce46..6cd7463 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.client; +import java.time.Duration; + import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Timeout; @@ -46,4 +48,9 @@ class WebFluxSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { container.stop(); } + @Override + protected Duration getTimeoutDuration() { + return Duration.ofMillis(300); + } + } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java index 20eeb1d..6b980da 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.client; +import java.time.Duration; + import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Timeout; @@ -46,4 +48,9 @@ class WebFluxSseMcpSyncClientTests extends AbstractMcpSyncClientTests { container.stop(); } + @Override + protected Duration getTimeoutDuration() { + return Duration.ofMillis(300); + } + } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 092d5af..cdcba4d 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -10,6 +10,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import io.modelcontextprotocol.spec.ClientMcpTransport; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; @@ -47,8 +48,6 @@ public abstract class AbstractMcpAsyncClientTests { protected ClientMcpTransport mcpTransport; - private static final Duration TIMEOUT = Duration.ofSeconds(20); - private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!"; abstract protected ClientMcpTransport createMcpTransport(); @@ -59,6 +58,10 @@ public abstract class AbstractMcpAsyncClientTests { protected void onClose() { } + protected Duration getTimeoutDuration() { + return Duration.ofSeconds(2); + } + @BeforeEach void setUp() { onStart(); @@ -66,10 +69,9 @@ public abstract class AbstractMcpAsyncClientTests { assertThatCode(() -> { mcpAsyncClient = McpClient.async(mcpTransport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .capabilities(ClientCapabilities.builder().roots(true).build()) .build(); - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); }).doesNotThrowAnyException(); } @@ -92,8 +94,16 @@ public abstract class AbstractMcpAsyncClientTests { .hasMessage("Request timeout must not be null"); } + @Test + void testListToolsWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.listTools(null).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing tools"); + } + @Test void testListTools() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.listTools(null)).consumeNextWith(result -> { assertThat(result.tools()).isNotNull().isNotEmpty(); @@ -103,13 +113,30 @@ public abstract class AbstractMcpAsyncClientTests { }).verifyComplete(); } + @Test + void testPingWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.ping().block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before pinging the server"); + } + @Test void testPing() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException(); } + @Test + void testCallToolWithoutInitialization() { + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); + + assertThatThrownBy(() -> mcpAsyncClient.callTool(callToolRequest).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before calling tools"); + } + @Test void testCallTool() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> { @@ -122,13 +149,23 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testCallToolWithInvalidTool() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE)); assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class); } + @Test + void testListResourcesWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.listResources(null).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resources"); + } + @Test void testListResources() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> { assertThat(resources).isNotNull().satisfies(result -> { assertThat(result.resources()).isNotNull(); @@ -147,8 +184,16 @@ public abstract class AbstractMcpAsyncClientTests { assertThat(mcpAsyncClient).isNotNull(); } + @Test + void testListPromptsWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.listPrompts(null).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing prompts"); + } + @Test void testListPrompts() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.listPrompts(null)).consumeNextWith(prompts -> { assertThat(prompts).isNotNull().satisfies(result -> { assertThat(result.prompts()).isNotNull(); @@ -162,8 +207,18 @@ public abstract class AbstractMcpAsyncClientTests { }).verifyComplete(); } + @Test + void testGetPromptWithoutInitialization() { + GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); + + assertThatThrownBy(() -> mcpAsyncClient.getPrompt(request).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before getting prompts"); + } + @Test void testGetPrompt() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of()))) .consumeNextWith(prompt -> { assertThat(prompt).isNotNull().satisfies(result -> { @@ -174,8 +229,16 @@ public abstract class AbstractMcpAsyncClientTests { .verifyComplete(); } + @Test + void testRootsListChangedWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.rootsListChangedNotification().block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before sending roots list changed notification"); + } + @Test void testRootsListChanged() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException(); } @@ -184,7 +247,7 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .roots(new Root("file:///test/path", "test-root")) .build(); @@ -233,8 +296,16 @@ public abstract class AbstractMcpAsyncClientTests { }).verifyComplete(); } + @Test + void testListResourceTemplatesWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.listResourceTemplates().block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resource templates"); + } + @Test void testListResourceTemplates() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.listResourceTemplates()).consumeNextWith(result -> { assertThat(result).isNotNull(); assertThat(result.resourceTemplates()).isNotNull(); @@ -266,7 +337,7 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) @@ -285,7 +356,7 @@ public abstract class AbstractMcpAsyncClientTests { var capabilities = ClientCapabilities.builder().sampling().build(); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .capabilities(capabilities) .sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build())) .build(); @@ -309,7 +380,7 @@ public abstract class AbstractMcpAsyncClientTests { Function> samplingHandler = request -> Mono .just(CreateMessageResult.builder().message("test").model("test-model").build()); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .capabilities(capabilities) .sampling(samplingHandler) .build(); @@ -326,8 +397,17 @@ public abstract class AbstractMcpAsyncClientTests { // Logging Tests // --------------------------------------- + @Test + void testLoggingLevelsWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG).block()) + .isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before setting logging level"); + } + @Test void testLoggingLevels() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + // Test all logging levels for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete(); @@ -340,7 +420,7 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))) .build(); diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 796dd5a..aeed06c 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -9,6 +9,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import io.modelcontextprotocol.spec.ClientMcpTransport; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -41,8 +42,6 @@ public abstract class AbstractMcpSyncClientTests { private McpSyncClient mcpSyncClient; - private static final Duration TIMEOUT = Duration.ofSeconds(10); - private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; protected ClientMcpTransport mcpTransport; @@ -53,6 +52,10 @@ public abstract class AbstractMcpSyncClientTests { abstract protected void onClose(); + protected Duration getTimeoutDuration() { + return Duration.ofSeconds(2); + } + @BeforeEach void setUp() { onStart(); @@ -60,10 +63,9 @@ public abstract class AbstractMcpSyncClientTests { assertThatCode(() -> { mcpSyncClient = McpClient.sync(mcpTransport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .capabilities(ClientCapabilities.builder().roots(true).build()) .build(); - mcpSyncClient.initialize(); }).doesNotThrowAnyException(); } @@ -85,8 +87,15 @@ public abstract class AbstractMcpSyncClientTests { .hasMessage("Request timeout must not be null"); } + @Test + void testListToolsWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.listTools(null)).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing tools"); + } + @Test void testListTools() { + mcpSyncClient.initialize(); ListToolsResult tools = mcpSyncClient.listTools(null); assertThat(tools).isNotNull().satisfies(result -> { @@ -98,8 +107,16 @@ public abstract class AbstractMcpSyncClientTests { }); } + @Test + void testCallToolsWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4)))) + .isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before calling tools"); + } + @Test void testCallTools() { + mcpSyncClient.initialize(); CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); assertThat(toolResult).isNotNull().satisfies(result -> { @@ -114,13 +131,29 @@ public abstract class AbstractMcpSyncClientTests { }); } + @Test + void testPingWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.ping()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before pinging the server"); + } + @Test void testPing() { + mcpSyncClient.initialize(); assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); } + @Test + void testCallToolWithoutInitialization() { + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); + + assertThatThrownBy(() -> mcpSyncClient.callTool(callToolRequest)).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before calling tools"); + } + @Test void testCallTool() { + mcpSyncClient.initialize(); CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); @@ -138,13 +171,27 @@ public abstract class AbstractMcpSyncClientTests { assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); } + @Test + void testRootsListChangedWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.rootsListChangedNotification()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before sending roots list changed notification"); + } + @Test void testRootsListChanged() { + mcpSyncClient.initialize(); assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException(); } + @Test + void testListResourcesWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.listResources(null)).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resources"); + } + @Test void testListResources() { + mcpSyncClient.initialize(); ListResourcesResult resources = mcpSyncClient.listResources(null); assertThat(resources).isNotNull().satisfies(result -> { @@ -168,7 +215,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .roots(new Root("file:///test/path", "test-root")) .build(); @@ -204,8 +251,17 @@ public abstract class AbstractMcpSyncClientTests { .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); } + @Test + void testReadResourceWithoutInitialization() { + assertThatThrownBy(() -> { + Resource resource = new Resource("test://uri", "Test Resource", null, null, null); + mcpSyncClient.readResource(resource); + }).isInstanceOf(McpError.class).hasMessage("Client must be initialized before reading resources"); + } + @Test void testReadResource() { + mcpSyncClient.initialize(); ListResourcesResult resources = mcpSyncClient.listResources(null); if (!resources.resources().isEmpty()) { @@ -217,8 +273,15 @@ public abstract class AbstractMcpSyncClientTests { } } + @Test + void testListResourceTemplatesWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.listResourceTemplates(null)).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resource templates"); + } + @Test void testListResourceTemplates() { + mcpSyncClient.initialize(); ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null); assertThat(result).isNotNull(); @@ -250,7 +313,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) @@ -266,8 +329,16 @@ public abstract class AbstractMcpSyncClientTests { // Logging Tests // --------------------------------------- + @Test + void testLoggingLevelsWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) + .isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before setting logging level"); + } + @Test void testLoggingLevels() { + mcpSyncClient.initialize(); // Test all logging levels for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException(); @@ -280,7 +351,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .loggingConsumer(notification -> logReceived.set(true)) .build(); diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index e1bf574..b301aa9 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -9,6 +9,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import com.fasterxml.jackson.core.type.TypeReference; @@ -35,6 +37,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; /** * The Model Context Protocol (MCP) client implementation that provides asynchronous @@ -79,6 +82,16 @@ public class McpAsyncClient { private static TypeReference VOID_TYPE_REFERENCE = new TypeReference<>() { }; + protected final Sinks.One initializedSink = Sinks.one(); + + private AtomicBoolean initialized = new AtomicBoolean(false); + + /** + * The max timeout to await for the client-server connection to be initialized. + * Usually x2 the request timeout. // TODO should we make it configurable? + */ + private final Duration initializationTimeout; + /** * The MCP session implementation that manages bidirectional JSON-RPC communication * between clients and servers. @@ -149,6 +162,7 @@ public class McpAsyncClient { this.clientCapabilities = features.clientCapabilities(); this.transport = transport; this.roots = new ConcurrentHashMap<>(features.roots()); + this.initializationTimeout = requestTimeout.multipliedBy(2); // Request Handlers Map> requestHandlers = new HashMap<>(); @@ -216,70 +230,6 @@ public class McpAsyncClient { } - // -------------------------- - // Lifecycle - // -------------------------- - /** - * The initialization phase MUST be the first interaction between client and server. - * During this phase, the client and server: - *

    - *
  • Establish protocol version compatibility
  • - *
  • Exchange and negotiate capabilities
  • - *
  • Share implementation details
  • - *
- *
- * The client MUST initiate this phase by sending an initialize request containing: - *
    - *
  • The protocol version the client supports
  • - *
  • The client's capabilities
  • - *
  • Client implementation information
  • - *
- * - * The server MUST respond with its own capabilities and information: - * {@link McpSchema.ServerCapabilities}.
- * After successful initialization, the client MUST send an initialized notification - * to indicate it is ready to begin normal operations. - * - *
- * - * Initialization - * Spec - * @return the initialize result. - */ - public Mono initialize() { - - String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1); - - McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off - latestVersion, - this.clientCapabilities, - this.clientInfo); // @formatter:on - - Mono result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE, - initializeRequest, new TypeReference() { - }); - - return result.flatMap(initializeResult -> { - - this.serverCapabilities = initializeResult.capabilities(); - this.serverInfo = initializeResult.serverInfo(); - - logger.info("Server response with Protocol: {}, Capabilities: {}, Info: {} and Instructions {}", - initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(), - initializeResult.instructions()); - - if (!this.protocolVersions.contains(initializeResult.protocolVersion())) { - return Mono.error(new McpError( - "Unsupported protocol version from the server: " + initializeResult.protocolVersion())); - } - else { - return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null) - .thenReturn(initializeResult); - } - }); - } - /** * Get the server capabilities that define the supported features and functionality. * @return The server capabilities @@ -301,7 +251,7 @@ public class McpAsyncClient { * @return true if the client-server connection is initialized */ public boolean isInitialized() { - return this.serverCapabilities != null; + return this.initialized.get(); } /** @@ -335,6 +285,82 @@ public class McpAsyncClient { return this.mcpSession.closeGracefully(); } + // -------------------------- + // Initialization + // -------------------------- + /** + * The initialization phase MUST be the first interaction between client and server. + * During this phase, the client and server: + *
    + *
  • Establish protocol version compatibility
  • + *
  • Exchange and negotiate capabilities
  • + *
  • Share implementation details
  • + *
+ *
+ * The client MUST initiate this phase by sending an initialize request containing: + * The protocol version the client supports, client's capabilities and clients + * implementation information. + *

+ * The server MUST respond with its own capabilities and information. + *

+ * After successful initialization, the client MUST send an initialized notification + * to indicate it is ready to begin normal operations. + * @return the initialize result. + * @see MCP + * Initialization Spec + */ + public Mono initialize() { + + String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1); + + McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off + latestVersion, + this.clientCapabilities, + this.clientInfo); // @formatter:on + + Mono result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE, + initializeRequest, new TypeReference() { + }); + + return result.flatMap(initializeResult -> { + + this.serverCapabilities = initializeResult.capabilities(); + this.serverInfo = initializeResult.serverInfo(); + + logger.info("Server response with Protocol: {}, Capabilities: {}, Info: {} and Instructions {}", + initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(), + initializeResult.instructions()); + + if (!this.protocolVersions.contains(initializeResult.protocolVersion())) { + return Mono.error(new McpError( + "Unsupported protocol version from the server: " + initializeResult.protocolVersion())); + } + + return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null).doOnSuccess(v -> { + this.initialized.set(true); + this.initializedSink.tryEmitValue(initializeResult); + }).thenReturn(initializeResult); + }); + } + + /** + * Utility method to handle the common pattern of checking initialization before + * executing an operation. + * @param The type of the result Mono + * @param actionName The action to perform if the client is initialized + * @param operation The operation to execute if the client is initialized + * @return A Mono that completes with the result of the operation + */ + private Mono withInitializationCheck(String actionName, + Function> operation) { + return this.initializedSink.asMono() + .timeout(this.initializationTimeout) + .onErrorResume(TimeoutException.class, + ex -> Mono.error(new McpError("Client must be initialized before " + actionName))) + .flatMap(operation); + } + // -------------------------- // Basic Utilites // -------------------------- @@ -344,8 +370,9 @@ public class McpAsyncClient { * @return A Mono that completes with the server's ping response */ public Mono ping() { - return this.mcpSession.sendRequest(McpSchema.METHOD_PING, null, new TypeReference() { - }); + return this.withInitializationCheck("pinging the server", initializedResult -> this.mcpSession + .sendRequest(McpSchema.METHOD_PING, null, new TypeReference() { + })); } // -------------------------- @@ -353,8 +380,8 @@ public class McpAsyncClient { // -------------------------- /** * Adds a new root to the client's root list. - * @param root The root to add - * @return A Mono that completes when the root is added and notifications are sent + * @param root The root to add. + * @return A Mono that completes when the root is added and notifications are sent. */ public Mono addRoot(Root root) { @@ -375,15 +402,20 @@ public class McpAsyncClient { logger.debug("Added root: {}", root); if (this.clientCapabilities.roots().listChanged()) { - return this.rootsListChangedNotification(); + if (this.isInitialized()) { + return this.rootsListChangedNotification(); + } + else { + logger.warn("Client is not initialized, ignore sending a roots list changed notification"); + } } return Mono.empty(); } /** * Removes a root from the client's root list. - * @param rootUri The URI of the root to remove - * @return A Mono that completes when the root is removed and notifications are sent + * @param rootUri The URI of the root to remove. + * @return A Mono that completes when the root is removed and notifications are sent. */ public Mono removeRoot(String rootUri) { @@ -400,7 +432,13 @@ public class McpAsyncClient { if (removed != null) { logger.debug("Removed Root: {}", rootUri); if (this.clientCapabilities.roots().listChanged()) { - return this.rootsListChangedNotification(); + if (this.isInitialized()) { + return this.rootsListChangedNotification(); + } + else { + logger.warn("Client is not initialized, ignore sending a roots list changed notification"); + } + } return Mono.empty(); } @@ -409,11 +447,13 @@ public class McpAsyncClient { /** * Manually sends a roots/list_changed notification. The addRoot and removeRoot - * methods automatically send the roots/list_changed notification. - * @return A Mono that completes when the notification is sent + * methods automatically send the roots/list_changed notification if the client is in + * an initialized state. + * @return A Mono that completes when the notification is sent. */ public Mono rootsListChangedNotification() { - return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED); + return this.withInitializationCheck("sending roots list changed notification", + initResult -> this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED)); } private RequestHandler rootsListRequestHandler() { @@ -455,29 +495,25 @@ public class McpAsyncClient { * Calls a tool provided by the server. Tools enable servers to expose executable * functionality that can interact with external systems, perform computations, and * take actions in the real world. - * @param callToolRequest The request containing: - name: The name of the tool to call - * (must match a tool name from tools/list) - arguments: Arguments that conform to the - * tool's input schema - * @return A Mono that emits the tool execution result containing: - content: List of - * content items (text, images, or embedded resources) representing the tool's output - * - isError: Boolean indicating if the execution failed (true) or succeeded - * (false/absent) + * @param callToolRequest The request containing the tool name and input parameters. + * @return A Mono that emits the result of the tool call, including the output and any + * errors. + * @see McpSchema.CallToolRequest + * @see McpSchema.CallToolResult + * @see #listTools() */ public Mono callTool(McpSchema.CallToolRequest callToolRequest) { - if (!this.isInitialized()) { - return Mono.error(new McpError("Client must be initialized before calling tools")); - } - if (this.serverCapabilities.tools() == null) { - return Mono.error(new McpError("Server does not provide tools capability")); - } - return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF); + return this.withInitializationCheck("calling tools", initializedResult -> { + if (this.serverCapabilities.tools() == null) { + return Mono.error(new McpError("Server does not provide tools capability")); + } + return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF); + }); } /** * Retrieves the list of all tools provided by the server. - * @return A Mono that emits the list of tools result containing: - tools: List of - * available tools, each with a name, description, and input schema - nextCursor: - * Optional cursor for pagination if more tools are available + * @return A Mono that emits the list of tools result. */ public Mono listTools() { return this.listTools(null); @@ -486,43 +522,29 @@ public class McpAsyncClient { /** * Retrieves a paginated list of tools provided by the server. * @param cursor Optional pagination cursor from a previous list request - * @return A Mono that emits the list of tools result containing: - tools: List of - * available tools, each with a name, description, and input schema - nextCursor: - * Optional cursor for pagination if more tools are available + * @return A Mono that emits the list of tools result */ public Mono listTools(String cursor) { - if (!this.isInitialized()) { - return Mono.error(new McpError("Client must be initialized before listing tools")); - } - if (this.serverCapabilities.tools() == null) { - return Mono.error(new McpError("Server does not provide tools capability")); - } - return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), - LIST_TOOLS_RESULT_TYPE_REF); + return this.withInitializationCheck("listing tools", initializedResult -> { + if (this.serverCapabilities.tools() == null) { + return Mono.error(new McpError("Server does not provide tools capability")); + } + return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), + LIST_TOOLS_RESULT_TYPE_REF); + }); } - /** - * Creates a notification handler for tools/list_changed notifications from the - * server. When the server's available tools change, it sends a notification to inform - * connected clients. This handler automatically fetches the updated tool list and - * distributes it to all registered consumers. - * @param toolsChangeConsumers List of consumers that will be notified when the tools - * list changes. Each consumer receives the complete updated list of tools. - * @return A NotificationHandler that processes tools/list_changed notifications by: - * 1. Fetching the current list of tools from the server 2. Distributing the updated - * list to all registered consumers 3. Handling any errors that occur during this - * process - */ private NotificationHandler asyncToolsChangeNotificationHandler( List, Mono>> toolsChangeConsumers) { // TODO: params are not used yet - return params -> listTools().flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers) - .flatMap(consumer -> consumer.apply(listToolsResult.tools())) - .onErrorResume(error -> { - logger.error("Error handling tools list change notification", error); - return Mono.empty(); - }) - .then()); + return params -> this.listTools() + .flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers) + .flatMap(consumer -> consumer.apply(listToolsResult.tools())) + .onErrorResume(error -> { + logger.error("Error handling tools list change notification", error); + return Mono.empty(); + }) + .then()); } // -------------------------- @@ -539,107 +561,122 @@ public class McpAsyncClient { }; /** - * Send a resources/list request. - * @return A Mono that completes with the list of resources result + * Retrieves the list of all resources provided by the server. Resources represent any + * kind of UTF-8 encoded data that an MCP server makes available to clients, such as + * database records, API responses, log files, and more. + * @return A Mono that completes with the list of resources result. + * @see McpSchema.ListResourcesResult + * @see #readResource(McpSchema.Resource) */ public Mono listResources() { return this.listResources(null); } /** - * Send a resources/list request. - * @param cursor the cursor for pagination - * @return A Mono that completes with the list of resources result + * Retrieves a paginated list of resources provided by the server. Resources represent + * any kind of UTF-8 encoded data that an MCP server makes available to clients, such + * as database records, API responses, log files, and more. + * @param cursor Optional pagination cursor from a previous list request. + * @return A Mono that completes with the list of resources result. + * @see McpSchema.ListResourcesResult + * @see #readResource(McpSchema.Resource) */ public Mono listResources(String cursor) { - if (!this.isInitialized()) { - return Mono.error(new McpError("Client must be initialized before listing resources")); - } - if (this.serverCapabilities.resources() == null) { - return Mono.error(new McpError("Server does not provide the resources capability")); - } - return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor), - LIST_RESOURCES_RESULT_TYPE_REF); + return this.withInitializationCheck("listing resources", initializedResult -> { + if (this.serverCapabilities.resources() == null) { + return Mono.error(new McpError("Server does not provide the resources capability")); + } + return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor), + LIST_RESOURCES_RESULT_TYPE_REF); + }); } /** - * Send a resources/read request. - * @param resource the resource to read - * @return A Mono that completes with the resource content + * Reads the content of a specific resource identified by the provided Resource + * object. This method fetches the actual data that the resource represents. + * @param resource The resource to read, containing the URI that identifies the + * resource. + * @return A Mono that completes with the resource content. + * @see McpSchema.Resource + * @see McpSchema.ReadResourceResult */ public Mono readResource(McpSchema.Resource resource) { return this.readResource(new McpSchema.ReadResourceRequest(resource.uri())); } /** - * Send a resources/read request. - * @param readResourceRequest the read resource request - * @return A Mono that completes with the resource content + * Reads the content of a specific resource identified by the provided request. This + * method fetches the actual data that the resource represents. + * @param readResourceRequest The request containing the URI of the resource to read + * @return A Mono that completes with the resource content. + * @see McpSchema.ReadResourceRequest + * @see McpSchema.ReadResourceResult */ public Mono readResource(McpSchema.ReadResourceRequest readResourceRequest) { - if (!this.isInitialized()) { - return Mono.error(new McpError("Client must be initialized before reading resources")); - } - if (this.serverCapabilities.resources() == null) { - return Mono.error(new McpError("Server does not provide the resources capability")); - } - return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, - READ_RESOURCE_RESULT_TYPE_REF); + return this.withInitializationCheck("reading resources", initializedResult -> { + if (this.serverCapabilities.resources() == null) { + return Mono.error(new McpError("Server does not provide the resources capability")); + } + return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, + READ_RESOURCE_RESULT_TYPE_REF); + }); } /** - * Resource templates allow servers to expose parameterized resources using URI - * templates. Arguments may be auto-completed through the completion API. - * - * Request a list of resource templates the server has. - * @return A Mono that completes with the list of resource templates result + * Retrieves the list of all resource templates provided by the server. Resource + * templates allow servers to expose parameterized resources using URI templates, + * enabling dynamic resource access based on variable parameters. + * @return A Mono that completes with the list of resource templates result. + * @see McpSchema.ListResourceTemplatesResult */ public Mono listResourceTemplates() { return this.listResourceTemplates(null); } /** - * Resource templates allow servers to expose parameterized resources using URI - * templates. Arguments may be auto-completed through the completion API. - * - * Request a list of resource templates the server has. - * @param cursor the cursor for pagination - * @return A Mono that completes with the list of resource templates result + * Retrieves a paginated list of resource templates provided by the server. Resource + * templates allow servers to expose parameterized resources using URI templates, + * enabling dynamic resource access based on variable parameters. + * @param cursor Optional pagination cursor from a previous list request. + * @return A Mono that completes with the list of resource templates result. + * @see McpSchema.ListResourceTemplatesResult */ public Mono listResourceTemplates(String cursor) { - if (!this.isInitialized()) { - return Mono.error(new McpError("Client must be initialized before listing resource templates")); - } - if (this.serverCapabilities.resources() == null) { - return Mono.error(new McpError("Server does not provide the resources capability")); - } - return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, - new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF); + return this.withInitializationCheck("listing resource templates", initializedResult -> { + if (this.serverCapabilities.resources() == null) { + return Mono.error(new McpError("Server does not provide the resources capability")); + } + return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, + new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF); + }); } /** - * Subscriptions. The protocol supports optional subscriptions to resource changes. - * Clients can subscribe to specific resources and receive notifications when they - * change. - * - * Send a resources/subscribe request. - * @param subscribeRequest the subscribe request contains the uri of the resource to - * subscribe to - * @return A Mono that completes when the subscription is complete + * Subscribes to changes in a specific resource. When the resource changes on the + * server, the client will receive notifications through the resources change + * notification handler. + * @param subscribeRequest The subscribe request containing the URI of the resource. + * @return A Mono that completes when the subscription is complete. + * @see McpSchema.SubscribeRequest + * @see #unsubscribeResource(McpSchema.UnsubscribeRequest) */ public Mono subscribeResource(McpSchema.SubscribeRequest subscribeRequest) { - return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_SUBSCRIBE, subscribeRequest, VOID_TYPE_REFERENCE); + return this.withInitializationCheck("subscribing to resources", initializedResult -> this.mcpSession + .sendRequest(McpSchema.METHOD_RESOURCES_SUBSCRIBE, subscribeRequest, VOID_TYPE_REFERENCE)); } /** - * Send a resources/unsubscribe request. - * @param unsubscribeRequest the unsubscribe request contains the uri of the resource - * to unsubscribe from - * @return A Mono that completes when the unsubscription is complete + * Cancels an existing subscription to a resource. After unsubscribing, the client + * will no longer receive notifications when the resource changes. + * @param unsubscribeRequest The unsubscribe request containing the URI of the + * resource. + * @return A Mono that completes when the unsubscription is complete. + * @see McpSchema.UnsubscribeRequest + * @see #subscribeResource(McpSchema.SubscribeRequest) */ public Mono unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) { - return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, unsubscribeRequest, - VOID_TYPE_REFERENCE); + return this.withInitializationCheck("unsubscribing from resources", initializedResult -> this.mcpSession + .sendRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, unsubscribeRequest, VOID_TYPE_REFERENCE)); } private NotificationHandler asyncResourcesChangeNotificationHandler( @@ -663,30 +700,39 @@ public class McpAsyncClient { }; /** - * List all available prompts. - * @return A Mono that completes with the list of prompts result + * Retrieves the list of all prompts provided by the server. + * @return A Mono that completes with the list of prompts result. + * @see McpSchema.ListPromptsResult + * @see #getPrompt(GetPromptRequest) */ public Mono listPrompts() { return this.listPrompts(null); } /** - * List all available prompts. - * @param cursor the cursor for pagination - * @return A Mono that completes with the list of prompts result + * Retrieves a paginated list of prompts provided by the server. + * @param cursor Optional pagination cursor from a previous list request + * @return A Mono that completes with the list of prompts result. + * @see McpSchema.ListPromptsResult + * @see #getPrompt(GetPromptRequest) */ public Mono listPrompts(String cursor) { - return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), - LIST_PROMPTS_RESULT_TYPE_REF); + return this.withInitializationCheck("listing prompts", initializedResult -> this.mcpSession + .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF)); } /** - * Get a prompt by its id. - * @param getPromptRequest the get prompt request - * @return A Mono that completes with the get prompt result + * Retrieves a specific prompt by its ID. This provides the complete prompt template + * including all parameters and instructions for generating AI content. + * @param getPromptRequest The request containing the ID of the prompt to retrieve. + * @return A Mono that completes with the prompt result. + * @see McpSchema.GetPromptRequest + * @see McpSchema.GetPromptResult + * @see #listPrompts() */ public Mono getPrompt(GetPromptRequest getPromptRequest) { - return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF); + return this.withInitializationCheck("getting prompts", initializedResult -> this.mcpSession + .sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF)); } private NotificationHandler asyncPromptsChangeNotificationHandler( @@ -703,14 +749,6 @@ public class McpAsyncClient { // -------------------------- // Logging // -------------------------- - /** - * Create a notification handler for logging notifications from the server. This - * handler automatically distributes logging messages to all registered consumers. - * @param loggingConsumers List of consumers that will be notified when a logging - * message is received. Each consumer receives the logging message notification. - * @return A NotificationHandler that processes log notifications by distributing the - * message to all registered consumers - */ private NotificationHandler asyncLoggingNotificationHandler( List>> loggingConsumers) { @@ -726,18 +764,21 @@ public class McpAsyncClient { } /** - * Client can set the minimum logging level it wants to receive from the server. - * @param loggingLevel the min logging level + * Sets the minimum logging level for messages received from the server. The client + * will only receive log messages at or above the specified severity level. + * @param loggingLevel The minimum logging level to receive. + * @return A Mono that completes when the logging level is set. + * @see McpSchema.LoggingLevel */ public Mono setLoggingLevel(LoggingLevel loggingLevel) { Assert.notNull(loggingLevel, "Logging level must not be null"); - String levelName = this.transport.unmarshalFrom(loggingLevel, new TypeReference() { + return this.withInitializationCheck("setting logging level", initializedResult -> { + String levelName = this.transport.unmarshalFrom(loggingLevel, new TypeReference() { + }); + Map params = Map.of("level", levelName); + return this.mcpSession.sendNotification(McpSchema.METHOD_LOGGING_SET_LEVEL, params); }); - - Map params = Map.of("level", levelName); - - return this.mcpSession.sendNotification(McpSchema.METHOD_LOGGING_SET_LEVEL, params); } /** diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index d32b191..661c629 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -10,6 +10,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import io.modelcontextprotocol.spec.ClientMcpTransport; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; @@ -48,8 +49,6 @@ public abstract class AbstractMcpAsyncClientTests { protected ClientMcpTransport mcpTransport; - private static final Duration TIMEOUT = Duration.ofSeconds(20); - private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!"; abstract protected ClientMcpTransport createMcpTransport(); @@ -60,6 +59,10 @@ public abstract class AbstractMcpAsyncClientTests { protected void onClose() { } + protected Duration getTimeoutDuration() { + return Duration.ofSeconds(2); + } + @BeforeEach void setUp() { onStart(); @@ -67,10 +70,9 @@ public abstract class AbstractMcpAsyncClientTests { assertThatCode(() -> { mcpAsyncClient = McpClient.async(mcpTransport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .capabilities(ClientCapabilities.builder().roots(true).build()) .build(); - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); }).doesNotThrowAnyException(); } @@ -93,8 +95,16 @@ public abstract class AbstractMcpAsyncClientTests { .hasMessage("Request timeout must not be null"); } + @Test + void testListToolsWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.listTools(null).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing tools"); + } + @Test void testListTools() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.listTools(null)).consumeNextWith(result -> { assertThat(result.tools()).isNotNull().isNotEmpty(); @@ -104,13 +114,30 @@ public abstract class AbstractMcpAsyncClientTests { }).verifyComplete(); } + @Test + void testPingWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.ping().block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before pinging the server"); + } + @Test void testPing() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException(); } + @Test + void testCallToolWithoutInitialization() { + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); + + assertThatThrownBy(() -> mcpAsyncClient.callTool(callToolRequest).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before calling tools"); + } + @Test void testCallTool() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> { @@ -123,13 +150,23 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testCallToolWithInvalidTool() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE)); assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class); } + @Test + void testListResourcesWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.listResources(null).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resources"); + } + @Test void testListResources() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> { assertThat(resources).isNotNull().satisfies(result -> { assertThat(result.resources()).isNotNull(); @@ -148,8 +185,16 @@ public abstract class AbstractMcpAsyncClientTests { assertThat(mcpAsyncClient).isNotNull(); } + @Test + void testListPromptsWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.listPrompts(null).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing prompts"); + } + @Test void testListPrompts() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.listPrompts(null)).consumeNextWith(prompts -> { assertThat(prompts).isNotNull().satisfies(result -> { assertThat(result.prompts()).isNotNull(); @@ -163,8 +208,18 @@ public abstract class AbstractMcpAsyncClientTests { }).verifyComplete(); } + @Test + void testGetPromptWithoutInitialization() { + GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); + + assertThatThrownBy(() -> mcpAsyncClient.getPrompt(request).block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before getting prompts"); + } + @Test void testGetPrompt() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of()))) .consumeNextWith(prompt -> { assertThat(prompt).isNotNull().satisfies(result -> { @@ -175,8 +230,16 @@ public abstract class AbstractMcpAsyncClientTests { .verifyComplete(); } + @Test + void testRootsListChangedWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.rootsListChangedNotification().block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before sending roots list changed notification"); + } + @Test void testRootsListChanged() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException(); } @@ -185,7 +248,7 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .roots(new Root("file:///test/path", "test-root")) .build(); @@ -234,8 +297,16 @@ public abstract class AbstractMcpAsyncClientTests { }).verifyComplete(); } + @Test + void testListResourceTemplatesWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.listResourceTemplates().block()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resource templates"); + } + @Test void testListResourceTemplates() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.listResourceTemplates()).consumeNextWith(result -> { assertThat(result).isNotNull(); assertThat(result.resourceTemplates()).isNotNull(); @@ -267,7 +338,7 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) @@ -286,7 +357,7 @@ public abstract class AbstractMcpAsyncClientTests { var capabilities = ClientCapabilities.builder().sampling().build(); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .capabilities(capabilities) .sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build())) .build(); @@ -310,7 +381,7 @@ public abstract class AbstractMcpAsyncClientTests { Function> samplingHandler = request -> Mono .just(CreateMessageResult.builder().message("test").model("test-model").build()); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .capabilities(capabilities) .sampling(samplingHandler) .build(); @@ -327,8 +398,17 @@ public abstract class AbstractMcpAsyncClientTests { // Logging Tests // --------------------------------------- + @Test + void testLoggingLevelsWithoutInitialization() { + assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG).block()) + .isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before setting logging level"); + } + @Test void testLoggingLevels() { + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + // Test all logging levels for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete(); @@ -341,7 +421,7 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))) .build(); diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index cbad541..6f8cf19 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -9,6 +9,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import io.modelcontextprotocol.spec.ClientMcpTransport; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -42,8 +43,6 @@ public abstract class AbstractMcpSyncClientTests { private McpSyncClient mcpSyncClient; - private static final Duration TIMEOUT = Duration.ofSeconds(10); - private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; protected ClientMcpTransport mcpTransport; @@ -54,6 +53,10 @@ public abstract class AbstractMcpSyncClientTests { abstract protected void onClose(); + protected Duration getTimeoutDuration() { + return Duration.ofSeconds(2); + } + @BeforeEach void setUp() { onStart(); @@ -61,10 +64,9 @@ public abstract class AbstractMcpSyncClientTests { assertThatCode(() -> { mcpSyncClient = McpClient.sync(mcpTransport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .capabilities(ClientCapabilities.builder().roots(true).build()) .build(); - mcpSyncClient.initialize(); }).doesNotThrowAnyException(); } @@ -86,8 +88,15 @@ public abstract class AbstractMcpSyncClientTests { .hasMessage("Request timeout must not be null"); } + @Test + void testListToolsWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.listTools(null)).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing tools"); + } + @Test void testListTools() { + mcpSyncClient.initialize(); ListToolsResult tools = mcpSyncClient.listTools(null); assertThat(tools).isNotNull().satisfies(result -> { @@ -99,8 +108,16 @@ public abstract class AbstractMcpSyncClientTests { }); } + @Test + void testCallToolsWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4)))) + .isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before calling tools"); + } + @Test void testCallTools() { + mcpSyncClient.initialize(); CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); assertThat(toolResult).isNotNull().satisfies(result -> { @@ -115,13 +132,29 @@ public abstract class AbstractMcpSyncClientTests { }); } + @Test + void testPingWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.ping()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before pinging the server"); + } + @Test void testPing() { + mcpSyncClient.initialize(); assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); } + @Test + void testCallToolWithoutInitialization() { + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); + + assertThatThrownBy(() -> mcpSyncClient.callTool(callToolRequest)).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before calling tools"); + } + @Test void testCallTool() { + mcpSyncClient.initialize(); CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); @@ -139,13 +172,27 @@ public abstract class AbstractMcpSyncClientTests { assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); } + @Test + void testRootsListChangedWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.rootsListChangedNotification()).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before sending roots list changed notification"); + } + @Test void testRootsListChanged() { + mcpSyncClient.initialize(); assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException(); } + @Test + void testListResourcesWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.listResources(null)).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resources"); + } + @Test void testListResources() { + mcpSyncClient.initialize(); ListResourcesResult resources = mcpSyncClient.listResources(null); assertThat(resources).isNotNull().satisfies(result -> { @@ -169,7 +216,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .roots(new Root("file:///test/path", "test-root")) .build(); @@ -205,8 +252,17 @@ public abstract class AbstractMcpSyncClientTests { .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); } + @Test + void testReadResourceWithoutInitialization() { + assertThatThrownBy(() -> { + Resource resource = new Resource("test://uri", "Test Resource", null, null, null); + mcpSyncClient.readResource(resource); + }).isInstanceOf(McpError.class).hasMessage("Client must be initialized before reading resources"); + } + @Test void testReadResource() { + mcpSyncClient.initialize(); ListResourcesResult resources = mcpSyncClient.listResources(null); if (!resources.resources().isEmpty()) { @@ -218,8 +274,15 @@ public abstract class AbstractMcpSyncClientTests { } } + @Test + void testListResourceTemplatesWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.listResourceTemplates(null)).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resource templates"); + } + @Test void testListResourceTemplates() { + mcpSyncClient.initialize(); ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null); assertThat(result).isNotNull(); @@ -251,7 +314,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) @@ -267,8 +330,16 @@ public abstract class AbstractMcpSyncClientTests { // Logging Tests // --------------------------------------- + @Test + void testLoggingLevelsWithoutInitialization() { + assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) + .isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before setting logging level"); + } + @Test void testLoggingLevels() { + mcpSyncClient.initialize(); // Test all logging levels for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException(); @@ -281,7 +352,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(TIMEOUT) + .requestTimeout(getTimeoutDuration()) .loggingConsumer(notification -> logReceived.set(true)) .build(); diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpAsyncClientTests.java index 56ac8f5..7cc673f 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpAsyncClientTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.client; +import java.time.Duration; + import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Timeout; @@ -44,4 +46,9 @@ class ServletSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { container.stop(); } + @Override + protected Duration getTimeoutDuration() { + return Duration.ofMillis(300); + } + } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpSyncClientTests.java index d76d0bf..2b8af41 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpSyncClientTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.client; +import java.time.Duration; + import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Timeout; @@ -44,4 +46,9 @@ class ServletSseMcpSyncClientTests extends AbstractMcpSyncClientTests { container.stop(); } + @Override + protected Duration getTimeoutDuration() { + return Duration.ofMillis(300); + } + } From ab0baf9d8e28e84fda85a258f22c0b43a12bd539 Mon Sep 17 00:00:00 2001 From: "He-Pin(kerr)" Date: Thu, 6 Mar 2025 17:24:21 +0800 Subject: [PATCH 014/393] chore: Make closeLatch a final. --- .../client/transport/HttpClientSseClientTransport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java b/mcp/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java index 7af5294..35da519 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java @@ -86,7 +86,7 @@ public class HttpClientSseClientTransport implements ClientMcpTransport { private volatile boolean isClosing = false; /** Latch for coordinating endpoint discovery */ - private CountDownLatch closeLatch = new CountDownLatch(1); + private final CountDownLatch closeLatch = new CountDownLatch(1); /** Holds the discovered message endpoint URL */ private final AtomicReference messageEndpoint = new AtomicReference<>(); From ec5383c23aa2241180593fafbfe5e3befc555bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Tue, 18 Mar 2025 13:23:35 +0100 Subject: [PATCH 015/393] Add CI GitHub Action and rename snapshot publish workflow --- .github/workflows/ci.yml | 22 +++++++++++++++++++ ...s-integration.yml => publish-snapshot.yml} | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml rename .github/workflows/{continuous-integration.yml => publish-snapshot.yml} (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c73d9f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: {} + +jobs: + build: + name: Build branch + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Build + run: mvn verify diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/publish-snapshot.yml similarity index 98% rename from .github/workflows/continuous-integration.yml rename to .github/workflows/publish-snapshot.yml index e0939f0..5d9b4aa 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/publish-snapshot.yml @@ -1,4 +1,4 @@ -name: CI/CD build +name: Publish Snapshot on: push: From 6ef1b580347cc2dc343e63a60fe194031250a947 Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Tue, 18 Mar 2025 19:18:54 +0100 Subject: [PATCH 016/393] Update README.md Fix the build status badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index caa6bf0..ca87736 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # MCP Java SDK -[![Build Status](https://github.com/modelcontextprotocol/java-sdk/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/modelcontextprotocol/java-sdk/actions/workflows/continuous-integration.yml) +[![Build Status](https://github.com/modelcontextprotocol/java-sdk/actions/workflows/publish-snapshot.yml/badge.svg)](https://github.com/modelcontextprotocol/java-sdk/actions/workflows/publish-snapshot.yml) A set of projects that provide Java SDK integration for the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture). This SDK enables Java applications to interact with AI models and tools through a standardized interface, supporting both synchronous and asynchronous communication patterns. From 1a673b35672921c541e4feccf3d7ac4cd60c34ec Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 18 Mar 2025 18:57:22 +0100 Subject: [PATCH 017/393] refactor: improve MCP client timeout handling and reactive testing - Add configurable initialization timeout separate from request timeout - Rename ServletSse* test classes to HttpSse* for better naming consistency - Replace direct .block() calls with StepVerifier for better reactive testing - Change ping() method to return Mono instead of Mono - Improve error handling and reactive programming patterns throughout tests - Chain reactive operations for cleaner test flow Signed-off-by: Christian Tzolov --- .../client/WebFluxSseMcpAsyncClientTests.java | 7 - .../client/WebFluxSseMcpSyncClientTests.java | 7 - .../client/AbstractMcpAsyncClientTests.java | 251 +++++++++-------- .../client/AbstractMcpSyncClientTests.java | 17 +- .../client/McpAsyncClient.java | 19 +- .../client/McpClient.java | 33 ++- .../client/McpSyncClient.java | 7 +- .../client/AbstractMcpAsyncClientTests.java | 253 +++++++++--------- .../client/AbstractMcpSyncClientTests.java | 15 +- ...s.java => HttpSseMcpAsyncClientTests.java} | 7 +- ...ts.java => HttpSseMcpSyncClientTests.java} | 7 +- .../client/StdioMcpSyncClientTests.java | 2 +- 12 files changed, 335 insertions(+), 290 deletions(-) rename mcp/src/test/java/io/modelcontextprotocol/client/{ServletSseMcpAsyncClientTests.java => HttpSseMcpAsyncClientTests.java} (89%) rename mcp/src/test/java/io/modelcontextprotocol/client/{ServletSseMcpSyncClientTests.java => HttpSseMcpSyncClientTests.java} (89%) diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java index 6cd7463..021ce46 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java @@ -4,8 +4,6 @@ package io.modelcontextprotocol.client; -import java.time.Duration; - import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Timeout; @@ -48,9 +46,4 @@ class WebFluxSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { container.stop(); } - @Override - protected Duration getTimeoutDuration() { - return Duration.ofMillis(300); - } - } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java index 6b980da..20eeb1d 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java @@ -4,8 +4,6 @@ package io.modelcontextprotocol.client; -import java.time.Duration; - import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Timeout; @@ -48,9 +46,4 @@ class WebFluxSseMcpSyncClientTests extends AbstractMcpSyncClientTests { container.stop(); } - @Override - protected Duration getTimeoutDuration() { - return Duration.ofMillis(300); - } - } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index cdcba4d..17cc996 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -58,8 +58,12 @@ public abstract class AbstractMcpAsyncClientTests { protected void onClose() { } - protected Duration getTimeoutDuration() { - return Duration.ofSeconds(2); + protected Duration getRequestTimeout() { + return Duration.ofSeconds(10); + } + + protected Duration getInitializationTimeout() { + return Duration.ofSeconds(1); } @BeforeEach @@ -69,7 +73,8 @@ public abstract class AbstractMcpAsyncClientTests { assertThatCode(() -> { mcpAsyncClient = McpClient.async(mcpTransport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) + .initializationTimeout(getInitializationTimeout()) .capabilities(ClientCapabilities.builder().roots(true).build()) .build(); }).doesNotThrowAnyException(); @@ -78,8 +83,7 @@ public abstract class AbstractMcpAsyncClientTests { @AfterEach void tearDown() { if (mcpAsyncClient != null) { - assertThatCode(() -> mcpAsyncClient.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); + StepVerifier.create(mcpAsyncClient.closeGracefully()).verifyComplete(); } onClose(); } @@ -96,87 +100,93 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testListToolsWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.listTools(null).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing tools"); + StepVerifier.create(mcpAsyncClient.listTools(null)).expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing tools"); + }).verify(); } @Test void testListTools() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools(null))) + .consumeNextWith(result -> { + assertThat(result.tools()).isNotNull().isNotEmpty(); - StepVerifier.create(mcpAsyncClient.listTools(null)).consumeNextWith(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }).verifyComplete(); + Tool firstTool = result.tools().get(0); + assertThat(firstTool.name()).isNotNull(); + assertThat(firstTool.description()).isNotNull(); + }) + .verifyComplete(); } @Test void testPingWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.ping().block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before pinging the server"); + StepVerifier.create(mcpAsyncClient.ping()) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before pinging the server")) + .verify(); } @Test void testPing() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())).verifyComplete(); } @Test void testCallToolWithoutInitialization() { CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - assertThatThrownBy(() -> mcpAsyncClient.callTool(callToolRequest).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before calling tools"); + StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before calling tools")) + .verify(); } @Test void testCallTool() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> { - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); - }); - }).verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest))) + .consumeNextWith(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.content()).isNotNull(); + assertThat(callToolResult.isError()).isNull(); + }) + .verifyComplete(); } @Test void testCallToolWithInvalidTool() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE)); - assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest))) + .expectError(Exception.class) + .verify(); } @Test void testListResourcesWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.listResources(null).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resources"); + StepVerifier.create(mcpAsyncClient.listResources(null)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before listing resources")) + .verify(); } @Test void testListResources() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources(null))) + .consumeNextWith(resources -> { + assertThat(resources).isNotNull().satisfies(result -> { + assertThat(result.resources()).isNotNull(); - StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> { - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }).verifyComplete(); + if (!result.resources().isEmpty()) { + Resource firstResource = result.resources().get(0); + assertThat(firstResource.uri()).isNotNull(); + assertThat(firstResource.name()).isNotNull(); + } + }); + }) + .verifyComplete(); } @Test @@ -186,40 +196,44 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testListPromptsWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.listPrompts(null).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing prompts"); + StepVerifier.create(mcpAsyncClient.listPrompts(null)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before listing prompts")) + .verify(); } @Test void testListPrompts() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts(null))) + .consumeNextWith(prompts -> { + assertThat(prompts).isNotNull().satisfies(result -> { + assertThat(result.prompts()).isNotNull(); - StepVerifier.create(mcpAsyncClient.listPrompts(null)).consumeNextWith(prompts -> { - assertThat(prompts).isNotNull().satisfies(result -> { - assertThat(result.prompts()).isNotNull(); - - if (!result.prompts().isEmpty()) { - Prompt firstPrompt = result.prompts().get(0); - assertThat(firstPrompt.name()).isNotNull(); - assertThat(firstPrompt.description()).isNotNull(); - } - }); - }).verifyComplete(); + if (!result.prompts().isEmpty()) { + Prompt firstPrompt = result.prompts().get(0); + assertThat(firstPrompt.name()).isNotNull(); + assertThat(firstPrompt.description()).isNotNull(); + } + }); + }) + .verifyComplete(); } @Test void testGetPromptWithoutInitialization() { GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - assertThatThrownBy(() -> mcpAsyncClient.getPrompt(request).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before getting prompts"); + StepVerifier.create(mcpAsyncClient.getPrompt(request)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before getting prompts")) + .verify(); } @Test void testGetPrompt() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - StepVerifier.create(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of()))) + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.getPrompt(request))) .consumeNextWith(prompt -> { assertThat(prompt).isNotNull().satisfies(result -> { assertThat(result.messages()).isNotEmpty(); @@ -231,15 +245,16 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testRootsListChangedWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.rootsListChangedNotification().block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before sending roots list changed notification"); + StepVerifier.create(mcpAsyncClient.rootsListChangedNotification()) + .expectErrorMatches(error -> error instanceof McpError && error.getMessage() + .equals("Client must be initialized before sending roots list changed notification")) + .verify(); } @Test void testRootsListChanged() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - - assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.rootsListChangedNotification())) + .verifyComplete(); } @Test @@ -247,39 +262,39 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .roots(new Root("file:///test/path", "test-root")) .build(); - assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - - assertThatCode(() -> client.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test void testAddRoot() { Root newRoot = new Root("file:///new/test/path", "new-test-root"); - assertThatCode(() -> mcpAsyncClient.addRoot(newRoot).block()).doesNotThrowAnyException(); + + StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete(); } @Test void testAddRootWithNullValue() { - assertThatThrownBy(() -> mcpAsyncClient.addRoot(null).block()).hasMessageContaining("Root must not be null"); + StepVerifier.create(mcpAsyncClient.addRoot(null)) + .expectErrorMatches(error -> error.getMessage().contains("Root must not be null")) + .verify(); } @Test void testRemoveRoot() { Root root = new Root("file:///test/path/to/remove", "root-to-remove"); - assertThatCode(() -> { - mcpAsyncClient.addRoot(root).block(); - mcpAsyncClient.removeRoot(root.uri()).block(); - }).doesNotThrowAnyException(); + + StepVerifier.create(mcpAsyncClient.addRoot(root).then(mcpAsyncClient.removeRoot(root.uri()))).verifyComplete(); } @Test void testRemoveNonExistentRoot() { - assertThatThrownBy(() -> mcpAsyncClient.removeRoot("nonexistent-uri").block()) - .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); + StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri")) + .expectErrorMatches(error -> error.getMessage().contains("Root with uri 'nonexistent-uri' not found")) + .verify(); } @Test @@ -298,18 +313,20 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testListResourceTemplatesWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.listResourceTemplates().block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resource templates"); + StepVerifier.create(mcpAsyncClient.listResourceTemplates()) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before listing resource templates")) + .verify(); } @Test void testListResourceTemplates() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - - StepVerifier.create(mcpAsyncClient.listResourceTemplates()).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }).verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) + .consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).isNotNull(); + }) + .verifyComplete(); } // @Test @@ -337,16 +354,13 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) .build(); - assertThatCode(() -> { - client.initialize().block(); - client.closeGracefully().block(); - }).doesNotThrowAnyException(); + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test @@ -356,15 +370,12 @@ public abstract class AbstractMcpAsyncClientTests { var capabilities = ClientCapabilities.builder().sampling().build(); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .capabilities(capabilities) .sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build())) .build(); - assertThatCode(() -> { - client.initialize().block(Duration.ofSeconds(10)); - client.closeGracefully().block(Duration.ofSeconds(10)); - }).doesNotThrowAnyException(); + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test @@ -380,17 +391,17 @@ public abstract class AbstractMcpAsyncClientTests { Function> samplingHandler = request -> Mono .just(CreateMessageResult.builder().message("test").model("test-model").build()); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .capabilities(capabilities) .sampling(samplingHandler) .build(); - assertThatCode(() -> { - var result = client.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(client.initialize()).consumeNextWith(result -> { assertThat(result).isNotNull(); assertThat(result.capabilities()).isNotNull(); - client.closeGracefully().block(Duration.ofSeconds(10)); - }).doesNotThrowAnyException(); + }).verifyComplete(); + + StepVerifier.create(client.closeGracefully()).verifyComplete(); } // --------------------------------------- @@ -399,19 +410,23 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testLoggingLevelsWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG).block()) - .isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before setting logging level"); + StepVerifier.create(mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before setting logging level")) + .verify(); } @Test void testLoggingLevels() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + Mono testAllLevels = mcpAsyncClient.initialize().then(Mono.defer(() -> { + Mono chain = Mono.empty(); + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + chain = chain.then(mcpAsyncClient.setLoggingLevel(level)); + } + return chain; + })); - // Test all logging levels - for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { - StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete(); - } + StepVerifier.create(testAllLevels).verifyComplete(); } @Test @@ -420,20 +435,18 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))) .build(); - assertThatCode(() -> { - client.initialize().block(Duration.ofSeconds(10)); - client.closeGracefully().block(Duration.ofSeconds(10)); - }).doesNotThrowAnyException(); + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test void testLoggingWithNullNotification() { - assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(null).block()) - .hasMessageContaining("Logging level must not be null"); + StepVerifier.create(mcpAsyncClient.setLoggingLevel(null)) + .expectErrorMatches(error -> error.getMessage().contains("Logging level must not be null")) + .verify(); } } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index aeed06c..ee43a57 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -52,8 +52,12 @@ public abstract class AbstractMcpSyncClientTests { abstract protected void onClose(); - protected Duration getTimeoutDuration() { - return Duration.ofSeconds(2); + protected Duration getRequestTimeout() { + return Duration.ofSeconds(10); + } + + protected Duration getInitializationTimeout() { + return Duration.ofSeconds(1); } @BeforeEach @@ -63,7 +67,8 @@ public abstract class AbstractMcpSyncClientTests { assertThatCode(() -> { mcpSyncClient = McpClient.sync(mcpTransport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) + .initializationTimeout(getInitializationTimeout()) .capabilities(ClientCapabilities.builder().roots(true).build()) .build(); }).doesNotThrowAnyException(); @@ -215,7 +220,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .roots(new Root("file:///test/path", "test-root")) .build(); @@ -313,7 +318,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) @@ -351,7 +356,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .loggingConsumer(notification -> logReceived.set(true)) .build(); diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index b301aa9..4c5fd02 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -88,7 +88,6 @@ public class McpAsyncClient { /** * The max timeout to await for the client-server connection to be initialized. - * Usually x2 the request timeout. // TODO should we make it configurable? */ private final Duration initializationTimeout; @@ -151,18 +150,21 @@ public class McpAsyncClient { * timeout. * @param transport the transport to use. * @param requestTimeout the session request-response timeout. + * @param initializationTimeout the max timeout to await for the client-server * @param features the MCP Client supported features. */ - McpAsyncClient(ClientMcpTransport transport, Duration requestTimeout, McpClientFeatures.Async features) { + McpAsyncClient(ClientMcpTransport transport, Duration requestTimeout, Duration initializationTimeout, + McpClientFeatures.Async features) { Assert.notNull(transport, "Transport must not be null"); Assert.notNull(requestTimeout, "Request timeout must not be null"); + Assert.notNull(initializationTimeout, "Initialization timeout must not be null"); this.clientInfo = features.clientInfo(); this.clientCapabilities = features.clientCapabilities(); this.transport = transport; this.roots = new ConcurrentHashMap<>(features.roots()); - this.initializationTimeout = requestTimeout.multipliedBy(2); + this.initializationTimeout = initializationTimeout; // Request Handlers Map> requestHandlers = new HashMap<>(); @@ -367,12 +369,13 @@ public class McpAsyncClient { /** * Sends a ping request to the server. - * @return A Mono that completes with the server's ping response + * @return A Mono that completes when the server responds to the ping */ - public Mono ping() { + public Mono ping() { return this.withInitializationCheck("pinging the server", initializedResult -> this.mcpSession .sendRequest(McpSchema.METHOD_PING, null, new TypeReference() { - })); + }) + .then()); } // -------------------------- @@ -771,7 +774,9 @@ public class McpAsyncClient { * @see McpSchema.LoggingLevel */ public Mono setLoggingLevel(LoggingLevel loggingLevel) { - Assert.notNull(loggingLevel, "Logging level must not be null"); + if (loggingLevel == null) { + return Mono.error(new McpError("Logging level must not be null")); + } return this.withInitializationCheck("setting logging level", initializedResult -> { String levelName = this.transport.unmarshalFrom(loggingLevel, new TypeReference() { diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java index 7ab01b7..fa2690d 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -157,6 +157,8 @@ public interface McpClient { private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout + private Duration initializationTimeout = Duration.ofSeconds(20); + private ClientCapabilities capabilities; private Implementation clientInfo = new Implementation("Java SDK MCP Client", "1.0.0"); @@ -193,6 +195,18 @@ public interface McpClient { return this; } + /** + * @param initializationTimeout The duration to wait for the initializaiton + * lifecycle step to complete. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if initializationTimeout is null + */ + public SyncSpec initializationTimeout(Duration initializationTimeout) { + Assert.notNull(initializationTimeout, "Initialization timeout must not be null"); + this.initializationTimeout = initializationTimeout; + return this; + } + /** * Sets the client capabilities that will be advertised to the server during * connection initialization. Capabilities define what features the client @@ -354,7 +368,8 @@ public interface McpClient { McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); - return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, asyncFeatures)); + return new McpSyncClient( + new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, asyncFeatures)); } } @@ -381,6 +396,8 @@ public interface McpClient { private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout + private Duration initializationTimeout = Duration.ofSeconds(20); + private ClientCapabilities capabilities; private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1"); @@ -417,6 +434,18 @@ public interface McpClient { return this; } + /** + * @param initializationTimeout The duration to wait for the initializaiton + * lifecycle step to complete. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if initializationTimeout is null + */ + public AsyncSpec initializationTimeout(Duration initializationTimeout) { + Assert.notNull(initializationTimeout, "Initialization timeout must not be null"); + this.initializationTimeout = initializationTimeout; + return this; + } + /** * Sets the client capabilities that will be advertised to the server during * connection initialization. Capabilities define what features the client @@ -574,7 +603,7 @@ public interface McpClient { * @return a new instance of {@link McpAsyncClient}. */ public McpAsyncClient build() { - return new McpAsyncClient(this.transport, this.requestTimeout, + return new McpAsyncClient(this.transport, this.requestTimeout, this.initializationTimeout, new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.samplingHandler)); diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index e5d964b..41f71d0 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -179,11 +179,10 @@ public class McpSyncClient implements AutoCloseable { } /** - * Send a synchronous ping request. - * @return + * Send a synchronous ping request to the server. */ - public Object ping() { - return this.delegate.ping().block(); + public void ping() { + this.delegate.ping().block(); } // -------------------------- diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 661c629..969c3a8 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -59,7 +59,11 @@ public abstract class AbstractMcpAsyncClientTests { protected void onClose() { } - protected Duration getTimeoutDuration() { + protected Duration getRequestTimeout() { + return Duration.ofSeconds(10); + } + + protected Duration getInitializationTimeout() { return Duration.ofSeconds(2); } @@ -70,7 +74,8 @@ public abstract class AbstractMcpAsyncClientTests { assertThatCode(() -> { mcpAsyncClient = McpClient.async(mcpTransport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) + .initializationTimeout(getInitializationTimeout()) .capabilities(ClientCapabilities.builder().roots(true).build()) .build(); }).doesNotThrowAnyException(); @@ -79,105 +84,110 @@ public abstract class AbstractMcpAsyncClientTests { @AfterEach void tearDown() { if (mcpAsyncClient != null) { - assertThatCode(() -> mcpAsyncClient.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); + StepVerifier.create(mcpAsyncClient.closeGracefully()).verifyComplete(); } onClose(); } @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpClient.async(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build()) + assertThatThrownBy(() -> McpClient.async(mcpTransport).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } @Test void testListToolsWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.listTools(null).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing tools"); + StepVerifier.create(mcpAsyncClient.listTools(null)).expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing tools"); + }).verify(); } @Test void testListTools() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools(null))) + .consumeNextWith(result -> { + assertThat(result.tools()).isNotNull().isNotEmpty(); - StepVerifier.create(mcpAsyncClient.listTools(null)).consumeNextWith(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }).verifyComplete(); + Tool firstTool = result.tools().get(0); + assertThat(firstTool.name()).isNotNull(); + assertThat(firstTool.description()).isNotNull(); + }) + .verifyComplete(); } @Test void testPingWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.ping().block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before pinging the server"); + StepVerifier.create(mcpAsyncClient.ping()) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before pinging the server")) + .verify(); } @Test void testPing() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())).verifyComplete(); } @Test void testCallToolWithoutInitialization() { CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - assertThatThrownBy(() -> mcpAsyncClient.callTool(callToolRequest).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before calling tools"); + StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before calling tools")) + .verify(); } @Test void testCallTool() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> { - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); - }); - }).verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest))) + .consumeNextWith(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.content()).isNotNull(); + assertThat(callToolResult.isError()).isNull(); + }) + .verifyComplete(); } @Test void testCallToolWithInvalidTool() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE)); - assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest))) + .expectError(Exception.class) + .verify(); } @Test void testListResourcesWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.listResources(null).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resources"); + StepVerifier.create(mcpAsyncClient.listResources(null)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before listing resources")) + .verify(); } @Test void testListResources() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources(null))) + .consumeNextWith(resources -> { + assertThat(resources).isNotNull().satisfies(result -> { + assertThat(result.resources()).isNotNull(); - StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> { - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }).verifyComplete(); + if (!result.resources().isEmpty()) { + Resource firstResource = result.resources().get(0); + assertThat(firstResource.uri()).isNotNull(); + assertThat(firstResource.name()).isNotNull(); + } + }); + }) + .verifyComplete(); } @Test @@ -187,40 +197,44 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testListPromptsWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.listPrompts(null).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing prompts"); + StepVerifier.create(mcpAsyncClient.listPrompts(null)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before listing prompts")) + .verify(); } @Test void testListPrompts() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts(null))) + .consumeNextWith(prompts -> { + assertThat(prompts).isNotNull().satisfies(result -> { + assertThat(result.prompts()).isNotNull(); - StepVerifier.create(mcpAsyncClient.listPrompts(null)).consumeNextWith(prompts -> { - assertThat(prompts).isNotNull().satisfies(result -> { - assertThat(result.prompts()).isNotNull(); - - if (!result.prompts().isEmpty()) { - Prompt firstPrompt = result.prompts().get(0); - assertThat(firstPrompt.name()).isNotNull(); - assertThat(firstPrompt.description()).isNotNull(); - } - }); - }).verifyComplete(); + if (!result.prompts().isEmpty()) { + Prompt firstPrompt = result.prompts().get(0); + assertThat(firstPrompt.name()).isNotNull(); + assertThat(firstPrompt.description()).isNotNull(); + } + }); + }) + .verifyComplete(); } @Test void testGetPromptWithoutInitialization() { GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - assertThatThrownBy(() -> mcpAsyncClient.getPrompt(request).block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before getting prompts"); + StepVerifier.create(mcpAsyncClient.getPrompt(request)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before getting prompts")) + .verify(); } @Test void testGetPrompt() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - StepVerifier.create(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of()))) + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.getPrompt(request))) .consumeNextWith(prompt -> { assertThat(prompt).isNotNull().satisfies(result -> { assertThat(result.messages()).isNotEmpty(); @@ -232,15 +246,16 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testRootsListChangedWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.rootsListChangedNotification().block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before sending roots list changed notification"); + StepVerifier.create(mcpAsyncClient.rootsListChangedNotification()) + .expectErrorMatches(error -> error instanceof McpError && error.getMessage() + .equals("Client must be initialized before sending roots list changed notification")) + .verify(); } @Test void testRootsListChanged() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - - assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.rootsListChangedNotification())) + .verifyComplete(); } @Test @@ -248,39 +263,39 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .roots(new Root("file:///test/path", "test-root")) .build(); - assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - - assertThatCode(() -> client.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test void testAddRoot() { Root newRoot = new Root("file:///new/test/path", "new-test-root"); - assertThatCode(() -> mcpAsyncClient.addRoot(newRoot).block()).doesNotThrowAnyException(); + + StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete(); } @Test void testAddRootWithNullValue() { - assertThatThrownBy(() -> mcpAsyncClient.addRoot(null).block()).hasMessageContaining("Root must not be null"); + StepVerifier.create(mcpAsyncClient.addRoot(null)) + .expectErrorMatches(error -> error.getMessage().contains("Root must not be null")) + .verify(); } @Test void testRemoveRoot() { Root root = new Root("file:///test/path/to/remove", "root-to-remove"); - assertThatCode(() -> { - mcpAsyncClient.addRoot(root).block(); - mcpAsyncClient.removeRoot(root.uri()).block(); - }).doesNotThrowAnyException(); + + StepVerifier.create(mcpAsyncClient.addRoot(root).then(mcpAsyncClient.removeRoot(root.uri()))).verifyComplete(); } @Test void testRemoveNonExistentRoot() { - assertThatThrownBy(() -> mcpAsyncClient.removeRoot("nonexistent-uri").block()) - .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); + StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri")) + .expectErrorMatches(error -> error.getMessage().contains("Root with uri 'nonexistent-uri' not found")) + .verify(); } @Test @@ -299,18 +314,20 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testListResourceTemplatesWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.listResourceTemplates().block()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resource templates"); + StepVerifier.create(mcpAsyncClient.listResourceTemplates()) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before listing resource templates")) + .verify(); } @Test void testListResourceTemplates() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); - - StepVerifier.create(mcpAsyncClient.listResourceTemplates()).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }).verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) + .consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).isNotNull(); + }) + .verifyComplete(); } // @Test @@ -338,16 +355,13 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) .build(); - assertThatCode(() -> { - client.initialize().block(); - client.closeGracefully().block(); - }).doesNotThrowAnyException(); + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test @@ -357,15 +371,12 @@ public abstract class AbstractMcpAsyncClientTests { var capabilities = ClientCapabilities.builder().sampling().build(); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .capabilities(capabilities) .sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build())) .build(); - assertThatCode(() -> { - client.initialize().block(Duration.ofSeconds(10)); - client.closeGracefully().block(Duration.ofSeconds(10)); - }).doesNotThrowAnyException(); + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test @@ -381,17 +392,17 @@ public abstract class AbstractMcpAsyncClientTests { Function> samplingHandler = request -> Mono .just(CreateMessageResult.builder().message("test").model("test-model").build()); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .capabilities(capabilities) .sampling(samplingHandler) .build(); - assertThatCode(() -> { - var result = client.initialize().block(Duration.ofSeconds(10)); + StepVerifier.create(client.initialize()).consumeNextWith(result -> { assertThat(result).isNotNull(); assertThat(result.capabilities()).isNotNull(); - client.closeGracefully().block(Duration.ofSeconds(10)); - }).doesNotThrowAnyException(); + }).verifyComplete(); + + StepVerifier.create(client.closeGracefully()).verifyComplete(); } // --------------------------------------- @@ -400,19 +411,23 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testLoggingLevelsWithoutInitialization() { - assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG).block()) - .isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before setting logging level"); + StepVerifier.create(mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) + .expectErrorMatches(error -> error instanceof McpError + && error.getMessage().equals("Client must be initialized before setting logging level")) + .verify(); } @Test void testLoggingLevels() { - mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + Mono testAllLevels = mcpAsyncClient.initialize().then(Mono.defer(() -> { + Mono chain = Mono.empty(); + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + chain = chain.then(mcpAsyncClient.setLoggingLevel(level)); + } + return chain; + })); - // Test all logging levels - for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { - StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete(); - } + StepVerifier.create(testAllLevels).verifyComplete(); } @Test @@ -421,20 +436,18 @@ public abstract class AbstractMcpAsyncClientTests { var transport = createMcpTransport(); var client = McpClient.async(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))) .build(); - assertThatCode(() -> { - client.initialize().block(Duration.ofSeconds(10)); - client.closeGracefully().block(Duration.ofSeconds(10)); - }).doesNotThrowAnyException(); + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test void testLoggingWithNullNotification() { - assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(null).block()) - .hasMessageContaining("Logging level must not be null"); + StepVerifier.create(mcpAsyncClient.setLoggingLevel(null)) + .expectErrorMatches(error -> error.getMessage().contains("Logging level must not be null")) + .verify(); } } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 6f8cf19..a866bfb 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -53,7 +53,11 @@ public abstract class AbstractMcpSyncClientTests { abstract protected void onClose(); - protected Duration getTimeoutDuration() { + protected Duration getRequestTimeout() { + return Duration.ofSeconds(10); + } + + protected Duration getInitializationTimeout() { return Duration.ofSeconds(2); } @@ -64,7 +68,8 @@ public abstract class AbstractMcpSyncClientTests { assertThatCode(() -> { mcpSyncClient = McpClient.sync(mcpTransport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) + .initializationTimeout(getInitializationTimeout()) .capabilities(ClientCapabilities.builder().roots(true).build()) .build(); }).doesNotThrowAnyException(); @@ -216,7 +221,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .roots(new Root("file:///test/path", "test-root")) .build(); @@ -314,7 +319,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) @@ -352,7 +357,7 @@ public abstract class AbstractMcpSyncClientTests { var transport = createMcpTransport(); var client = McpClient.sync(transport) - .requestTimeout(getTimeoutDuration()) + .requestTimeout(getRequestTimeout()) .loggingConsumer(notification -> logReceived.set(true)) .build(); diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java similarity index 89% rename from mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpAsyncClientTests.java rename to mcp/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java index 7cc673f..ac0fef2 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java @@ -18,7 +18,7 @@ import org.testcontainers.containers.wait.strategy.Wait; * @author Christian Tzolov */ @Timeout(15) // Giving extra time beyond the client timeout -class ServletSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { +class HttpSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { String host = "http://localhost:3004"; @@ -46,9 +46,4 @@ class ServletSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { container.stop(); } - @Override - protected Duration getTimeoutDuration() { - return Duration.ofMillis(300); - } - } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java similarity index 89% rename from mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpSyncClientTests.java rename to mcp/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java index 2b8af41..8772e62 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/ServletSseMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java @@ -18,7 +18,7 @@ import org.testcontainers.containers.wait.strategy.Wait; * @author Christian Tzolov */ @Timeout(15) // Giving extra time beyond the client timeout -class ServletSseMcpSyncClientTests extends AbstractMcpSyncClientTests { +class HttpSseMcpSyncClientTests extends AbstractMcpSyncClientTests { String host = "http://localhost:3003"; @@ -46,9 +46,4 @@ class ServletSseMcpSyncClientTests extends AbstractMcpSyncClientTests { container.stop(); } - @Override - protected Duration getTimeoutDuration() { - return Duration.ofMillis(300); - } - } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java index 7ae6525..3517008 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java @@ -39,7 +39,7 @@ class StdioMcpSyncClientTests extends AbstractMcpSyncClientTests { ((StdioClientTransport) mcpTransport).setStdErrorHandler(error -> receivedError.set(error)); String errorMessage = "Test error"; - ((StdioClientTransport) mcpTransport).getErrorSink().tryEmitNext(errorMessage); + ((StdioClientTransport) mcpTransport).getErrorSink().emitNext(errorMessage, null); assertThat(receivedError.get()).isNotNull().isEqualTo(errorMessage); } From 914a14a92a53009125fbe85e61dba5937a8cd66f Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 19 Mar 2025 08:27:38 +0100 Subject: [PATCH 018/393] adjust test timeout values Signed-off-by: Christian Tzolov --- .../client/WebFluxSseMcpAsyncClientTests.java | 6 ++++++ .../client/WebFluxSseMcpSyncClientTests.java | 6 ++++++ .../client/AbstractMcpAsyncClientTests.java | 6 +++--- .../client/AbstractMcpSyncClientTests.java | 8 +++++--- .../client/AbstractMcpSyncClientTests.java | 6 ++++-- .../client/StdioMcpAsyncClientTests.java | 6 ++++++ .../client/StdioMcpSyncClientTests.java | 9 +++------ 7 files changed, 33 insertions(+), 14 deletions(-) diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java index 021ce46..0dccb27 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.client; +import java.time.Duration; + import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Timeout; @@ -46,4 +48,8 @@ class WebFluxSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { container.stop(); } + protected Duration getInitializationTimeout() { + return Duration.ofSeconds(1); + } + } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java index 20eeb1d..f5cab7b 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.client; +import java.time.Duration; + import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Timeout; @@ -46,4 +48,8 @@ class WebFluxSseMcpSyncClientTests extends AbstractMcpSyncClientTests { container.stop(); } + protected Duration getInitializationTimeout() { + return Duration.ofSeconds(1); + } + } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 17cc996..2aa659c 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -63,7 +63,7 @@ public abstract class AbstractMcpAsyncClientTests { } protected Duration getInitializationTimeout() { - return Duration.ofSeconds(1); + return Duration.ofSeconds(2); } @BeforeEach @@ -90,10 +90,10 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpClient.async(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build()) + assertThatThrownBy(() -> McpClient.async(mcpTransport).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index ee43a57..d1b752f 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -48,16 +48,18 @@ public abstract class AbstractMcpSyncClientTests { abstract protected ClientMcpTransport createMcpTransport(); - abstract protected void onStart(); + protected void onStart() { + } - abstract protected void onClose(); + protected void onClose() { + } protected Duration getRequestTimeout() { return Duration.ofSeconds(10); } protected Duration getInitializationTimeout() { - return Duration.ofSeconds(1); + return Duration.ofSeconds(2); } @BeforeEach diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index a866bfb..726632f 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -49,9 +49,11 @@ public abstract class AbstractMcpSyncClientTests { abstract protected ClientMcpTransport createMcpTransport(); - abstract protected void onStart(); + protected void onStart() { + } - abstract protected void onClose(); + protected void onClose() { + } protected Duration getRequestTimeout() { return Duration.ofSeconds(10); diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java index ce74812..c285e2c 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java @@ -4,6 +4,8 @@ package io.modelcontextprotocol.client; +import java.time.Duration; + import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; @@ -26,4 +28,8 @@ class StdioMcpAsyncClientTests extends AbstractMcpAsyncClientTests { return new StdioClientTransport(stdioParams); } + protected Duration getInitializationTimeout() { + return Duration.ofSeconds(6); + } + } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java index 3517008..ec35162 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.client; +import java.time.Duration; import java.util.concurrent.atomic.AtomicReference; import io.modelcontextprotocol.client.transport.ServerParameters; @@ -44,12 +45,8 @@ class StdioMcpSyncClientTests extends AbstractMcpSyncClientTests { assertThat(receivedError.get()).isNotNull().isEqualTo(errorMessage); } - @Override - protected void onStart() { - } - - @Override - protected void onClose() { + protected Duration getInitializationTimeout() { + return Duration.ofSeconds(6); } } From 264a0c90412558884dee9ed1db1c9bd9d4047afa Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 19 Mar 2025 10:04:37 +0100 Subject: [PATCH 019/393] Address review comments Signed-off-by: Christian Tzolov --- .../client/AbstractMcpAsyncClientTests.java | 3 ++- .../io/modelcontextprotocol/client/McpAsyncClient.java | 7 +++---- .../java/io/modelcontextprotocol/client/McpSyncClient.java | 7 ++++--- .../client/AbstractMcpAsyncClientTests.java | 3 ++- .../client/StdioMcpSyncClientTests.java | 3 ++- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 2aa659c..91dd223 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -129,7 +129,8 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testPing() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())).verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())).consumeNextWith(callToolResult -> { + }).verifyComplete(); } @Test diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 4c5fd02..278e360 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -369,13 +369,12 @@ public class McpAsyncClient { /** * Sends a ping request to the server. - * @return A Mono that completes when the server responds to the ping + * @return A Mono that completes with the server's ping response */ - public Mono ping() { + public Mono ping() { return this.withInitializationCheck("pinging the server", initializedResult -> this.mcpSession .sendRequest(McpSchema.METHOD_PING, null, new TypeReference() { - }) - .then()); + })); } // -------------------------- diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index 41f71d0..e5d964b 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -179,10 +179,11 @@ public class McpSyncClient implements AutoCloseable { } /** - * Send a synchronous ping request to the server. + * Send a synchronous ping request. + * @return */ - public void ping() { - this.delegate.ping().block(); + public Object ping() { + return this.delegate.ping().block(); } // -------------------------- diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 969c3a8..1bc40c5 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -130,7 +130,8 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testPing() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())).verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())).consumeNextWith(callToolResult -> { + }).verifyComplete(); } @Test diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java index ec35162..6d759b4 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java @@ -12,6 +12,7 @@ import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import reactor.core.publisher.Sinks; import static org.assertj.core.api.Assertions.assertThat; @@ -40,7 +41,7 @@ class StdioMcpSyncClientTests extends AbstractMcpSyncClientTests { ((StdioClientTransport) mcpTransport).setStdErrorHandler(error -> receivedError.set(error)); String errorMessage = "Test error"; - ((StdioClientTransport) mcpTransport).getErrorSink().emitNext(errorMessage, null); + ((StdioClientTransport) mcpTransport).getErrorSink().emitNext(errorMessage, Sinks.EmitFailureHandler.FAIL_FAST); assertThat(receivedError.get()).isNotNull().isEqualTo(errorMessage); } From 92ec67a9650cc1c81f09de71c569345895d251fe Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 19 Mar 2025 10:26:14 +0100 Subject: [PATCH 020/393] Increase the request timeout to 14 sec Signed-off-by: Christian Tzolov --- .../client/AbstractMcpAsyncClientTests.java | 2 +- .../modelcontextprotocol/client/AbstractMcpSyncClientTests.java | 2 +- .../client/AbstractMcpAsyncClientTests.java | 2 +- .../modelcontextprotocol/client/AbstractMcpSyncClientTests.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 91dd223..a8a59a6 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -59,7 +59,7 @@ public abstract class AbstractMcpAsyncClientTests { } protected Duration getRequestTimeout() { - return Duration.ofSeconds(10); + return Duration.ofSeconds(14); } protected Duration getInitializationTimeout() { diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index d1b752f..0f83e31 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -55,7 +55,7 @@ public abstract class AbstractMcpSyncClientTests { } protected Duration getRequestTimeout() { - return Duration.ofSeconds(10); + return Duration.ofSeconds(14); } protected Duration getInitializationTimeout() { diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 1bc40c5..39bc499 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -60,7 +60,7 @@ public abstract class AbstractMcpAsyncClientTests { } protected Duration getRequestTimeout() { - return Duration.ofSeconds(10); + return Duration.ofSeconds(14); } protected Duration getInitializationTimeout() { diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 726632f..52a0138 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -56,7 +56,7 @@ public abstract class AbstractMcpSyncClientTests { } protected Duration getRequestTimeout() { - return Duration.ofSeconds(10); + return Duration.ofSeconds(14); } protected Duration getInitializationTimeout() { From 37120f2ae585ee68e74b3c6734239797b84b4fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Wed, 19 Mar 2025 15:08:43 +0100 Subject: [PATCH 021/393] Improve client test reliability and execution time This change uses VirtualTimeScheduler and pretends enough time has passed to trigger a timeout on the initialization. Another problem with reliability of the tests was that the used testcontainer for the SSE server does not support multiple clients and the existence of both the global client for the entire suite and some customized local clients in some tests caused responses to be delivered to the other client at some racing situations. Now each test creates a dedicated client and performs cleanup locally. While these tests were improved, two other issues were found and fixed. The first one is that the closeGracefully of DefaultMcpSession was not lazy and would trigger connection disposal before the returned Mono was subscribed. The second one was dealing with closing the StdIo client before the process was started. In such a case there should not be an error but rather a warning and successful completion. --- .../client/AbstractMcpAsyncClientTests.java | 511 +++++++++++------- .../client/AbstractMcpSyncClientTests.java | 361 ++++++++----- .../transport/StdioClientTransport.java | 7 +- .../spec/DefaultMcpSession.java | 6 +- .../client/AbstractMcpAsyncClientTests.java | 460 +++++++++------- .../client/AbstractMcpSyncClientTests.java | 361 ++++++++----- .../client/StdioMcpSyncClientTests.java | 20 +- 7 files changed, 1012 insertions(+), 714 deletions(-) diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index a8a59a6..033139a 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -6,7 +6,10 @@ package io.modelcontextprotocol.client; import java.time.Duration; import java.util.Map; +import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.function.Function; import io.modelcontextprotocol.spec.ClientMcpTransport; @@ -44,10 +47,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; */ public abstract class AbstractMcpAsyncClientTests { - private McpAsyncClient mcpAsyncClient; - - protected ClientMcpTransport mcpTransport; - private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!"; abstract protected ClientMcpTransport createMcpTransport(); @@ -66,25 +65,47 @@ public abstract class AbstractMcpAsyncClientTests { return Duration.ofSeconds(2); } + McpAsyncClient client(ClientMcpTransport transport) { + return client(transport, Function.identity()); + } + + McpAsyncClient client(ClientMcpTransport transport, Function customizer) { + AtomicReference client = new AtomicReference<>(); + + assertThatCode(() -> { + McpClient.AsyncSpec builder = McpClient.async(transport) + .requestTimeout(getRequestTimeout()) + .initializationTimeout(getInitializationTimeout()) + .capabilities(ClientCapabilities.builder().roots(true).build()); + builder = customizer.apply(builder); + client.set(builder.build()); + }).doesNotThrowAnyException(); + + return client.get(); + } + + void withClient(ClientMcpTransport transport, Consumer c) { + withClient(transport, Function.identity(), c); + } + + void withClient(ClientMcpTransport transport, Function customizer, + Consumer c) { + var client = client(transport, customizer); + try { + c.accept(client); + } + finally { + StepVerifier.create(client.closeGracefully()).expectComplete().verify(Duration.ofSeconds(10)); + } + } + @BeforeEach void setUp() { onStart(); - this.mcpTransport = createMcpTransport(); - - assertThatCode(() -> { - mcpAsyncClient = McpClient.async(mcpTransport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .capabilities(ClientCapabilities.builder().roots(true).build()) - .build(); - }).doesNotThrowAnyException(); } @AfterEach void tearDown() { - if (mcpAsyncClient != null) { - StepVerifier.create(mcpAsyncClient.closeGracefully()).verifyComplete(); - } onClose(); } @@ -93,258 +114,323 @@ public abstract class AbstractMcpAsyncClientTests { assertThatThrownBy(() -> McpClient.async(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.async(mcpTransport).requestTimeout(null).build()) + assertThatThrownBy(() -> McpClient.async(createMcpTransport()).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } @Test void testListToolsWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.listTools(null)).expectErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing tools"); - }).verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.withVirtualTime(() -> mcpAsyncClient.listTools(null)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing tools")) + .verify(); + }); } @Test void testListTools() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools(null))) - .consumeNextWith(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools(null))) + .consumeNextWith(result -> { + assertThat(result.tools()).isNotNull().isNotEmpty(); - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }) - .verifyComplete(); + Tool firstTool = result.tools().get(0); + assertThat(firstTool.name()).isNotNull(); + assertThat(firstTool.description()).isNotNull(); + }) + .verifyComplete(); + }); } @Test void testPingWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.ping()) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before pinging the server")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.withVirtualTime(() -> mcpAsyncClient.ping()) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before pinging the " + "server")) + .verify(); + }); } @Test void testPing() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())).consumeNextWith(callToolResult -> { - }).verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())) + .expectNextCount(1) + .verifyComplete(); + }); } @Test void testCallToolWithoutInitialization() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); + withClient(createMcpTransport(), mcpAsyncClient -> { + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before calling tools")) - .verify(); + StepVerifier.withVirtualTime(() -> mcpAsyncClient.callTool(callToolRequest)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before calling tools")) + .verify(); + }); } @Test void testCallTool() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); + withClient(createMcpTransport(), mcpAsyncClient -> { + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest))) - .consumeNextWith(callToolResult -> { - assertThat(callToolResult).isNotNull(); - assertThat(callToolResult.content()).isNotNull(); - assertThat(callToolResult.isError()).isNull(); - }) - .verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest))) + .consumeNextWith(callToolResult -> { + assertThat(callToolResult).isNotNull().satisfies(result -> { + assertThat(result.content()).isNotNull(); + assertThat(result.isError()).isNull(); + }); + }) + .verifyComplete(); + }); } @Test void testCallToolWithInvalidTool() { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE)); + withClient(createMcpTransport(), mcpAsyncClient -> { + CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", + Map.of("message", ECHO_TEST_MESSAGE)); - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest))) - .expectError(Exception.class) - .verify(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest))) + .consumeErrorWith( + e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Unknown tool: nonexistent_tool")) + .verify(); + }); } @Test void testListResourcesWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.listResources(null)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before listing resources")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.withVirtualTime(() -> mcpAsyncClient.listResources(null)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resources")) + .verify(); + }); } @Test void testListResources() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources(null))) - .consumeNextWith(resources -> { - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources(null))) + .consumeNextWith(resources -> { + assertThat(resources).isNotNull().satisfies(result -> { + assertThat(result.resources()).isNotNull(); - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }) - .verifyComplete(); + if (!result.resources().isEmpty()) { + Resource firstResource = result.resources().get(0); + assertThat(firstResource.uri()).isNotNull(); + assertThat(firstResource.name()).isNotNull(); + } + }); + }) + .verifyComplete(); + }); } @Test void testMcpAsyncClientState() { - assertThat(mcpAsyncClient).isNotNull(); + withClient(createMcpTransport(), mcpAsyncClient -> { + assertThat(mcpAsyncClient).isNotNull(); + }); } @Test void testListPromptsWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.listPrompts(null)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before listing prompts")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.withVirtualTime(() -> mcpAsyncClient.listPrompts(null)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing prompts")) + .verify(); + }); } @Test void testListPrompts() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts(null))) - .consumeNextWith(prompts -> { - assertThat(prompts).isNotNull().satisfies(result -> { - assertThat(result.prompts()).isNotNull(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts(null))) + .consumeNextWith(prompts -> { + assertThat(prompts).isNotNull().satisfies(result -> { + assertThat(result.prompts()).isNotNull(); - if (!result.prompts().isEmpty()) { - Prompt firstPrompt = result.prompts().get(0); - assertThat(firstPrompt.name()).isNotNull(); - assertThat(firstPrompt.description()).isNotNull(); - } - }); - }) - .verifyComplete(); + if (!result.prompts().isEmpty()) { + Prompt firstPrompt = result.prompts().get(0); + assertThat(firstPrompt.name()).isNotNull(); + assertThat(firstPrompt.description()).isNotNull(); + } + }); + }) + .verifyComplete(); + }); } @Test void testGetPromptWithoutInitialization() { - GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); + withClient(createMcpTransport(), mcpAsyncClient -> { + GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - StepVerifier.create(mcpAsyncClient.getPrompt(request)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before getting prompts")) - .verify(); + StepVerifier.withVirtualTime(() -> mcpAsyncClient.getPrompt(request)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before getting prompts")) + .verify(); + }); } @Test void testGetPrompt() { - GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.getPrompt(request))) - .consumeNextWith(prompt -> { - assertThat(prompt).isNotNull().satisfies(result -> { - assertThat(result.messages()).isNotEmpty(); - assertThat(result.messages()).hasSize(1); - }); - }) - .verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier + .create(mcpAsyncClient.initialize() + .then(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of())))) + .consumeNextWith(prompt -> { + assertThat(prompt).isNotNull().satisfies(result -> { + assertThat(result.messages()).isNotEmpty(); + assertThat(result.messages()).hasSize(1); + }); + }) + .verifyComplete(); + }); } @Test void testRootsListChangedWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.rootsListChangedNotification()) - .expectErrorMatches(error -> error instanceof McpError && error.getMessage() - .equals("Client must be initialized before sending roots list changed notification")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.withVirtualTime(() -> mcpAsyncClient.rootsListChangedNotification()) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before sending roots list changed notification")) + .verify(); + }); } @Test void testRootsListChanged() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.rootsListChangedNotification())) - .verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.rootsListChangedNotification())) + .verifyComplete(); + }); } @Test void testInitializeWithRootsListProviders() { - var transport = createMcpTransport(); - - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .roots(new Root("file:///test/path", "test-root")) - .build(); - - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); + withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), + client -> { + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); + }); } @Test void testAddRoot() { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); - - StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + Root newRoot = new Root("file:///new/test/path", "new-test-root"); + StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete(); + }); } @Test void testAddRootWithNullValue() { - StepVerifier.create(mcpAsyncClient.addRoot(null)) - .expectErrorMatches(error -> error.getMessage().contains("Root must not be null")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.addRoot(null)) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Root must not be null")) + .verify(); + }); } @Test void testRemoveRoot() { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + withClient(createMcpTransport(), mcpAsyncClient -> { + Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + StepVerifier.create(mcpAsyncClient.addRoot(root)).verifyComplete(); - StepVerifier.create(mcpAsyncClient.addRoot(root).then(mcpAsyncClient.removeRoot(root.uri()))).verifyComplete(); + StepVerifier.create(mcpAsyncClient.removeRoot(root.uri())).verifyComplete(); + }); } @Test void testRemoveNonExistentRoot() { - StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri")) - .expectErrorMatches(error -> error.getMessage().contains("Root with uri 'nonexistent-uri' not found")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri")) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Root with uri 'nonexistent-uri' not found")) + .verify(); + }); } @Test @Disabled void testReadResource() { - StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); - StepVerifier.create(mcpAsyncClient.readResource(firstResource)).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull(); - }).verifyComplete(); - } - }).verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); + StepVerifier.create(mcpAsyncClient.readResource(firstResource)).consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).isNotNull(); + }).verifyComplete(); + } + }).verifyComplete(); + }); } @Test void testListResourceTemplatesWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.listResourceTemplates()) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before listing resource templates")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.withVirtualTime(() -> mcpAsyncClient.listResourceTemplates()) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before listing resource templates")) + .verify(); + }); } @Test void testListResourceTemplates() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) - .consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }) - .verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) + .consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).isNotNull(); + }) + .verifyComplete(); + }); } // @Test void testResourceSubscription() { - StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); - // Test subscribe - StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) - .verifyComplete(); + // Test subscribe + StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) + .verifyComplete(); - // Test unsubscribe - StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) - .verifyComplete(); - } - }).verifyComplete(); + // Test unsubscribe + StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) + .verifyComplete(); + } + }).verifyComplete(); + }); } @Test @@ -353,36 +439,44 @@ public abstract class AbstractMcpAsyncClientTests { AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); - var transport = createMcpTransport(); - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) - .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) - .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) - .build(); + withClient(createMcpTransport(), + builder -> builder + .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) + .resourcesChangeConsumer( + resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) + .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))), + mcpAsyncClient -> { - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); + var transport = createMcpTransport(); + var client = McpClient.async(transport) + .requestTimeout(getRequestTimeout()) + .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) + .resourcesChangeConsumer( + resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) + .promptsChangeConsumer( + prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) + .build(); + + StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); + }); } @Test void testInitializeWithSamplingCapability() { - var transport = createMcpTransport(); - - var capabilities = ClientCapabilities.builder().sampling().build(); - - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .capabilities(capabilities) - .sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build())) + ClientCapabilities capabilities = ClientCapabilities.builder().sampling().build(); + CreateMessageResult createMessageResult = CreateMessageResult.builder() + .message("test") + .model("test-model") .build(); - - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); + withClient(createMcpTransport(), + builder -> builder.capabilities(capabilities).sampling(request -> Mono.just(createMessageResult)), + client -> { + StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); + }); } @Test void testInitializeWithAllCapabilities() { - var transport = createMcpTransport(); - var capabilities = ClientCapabilities.builder() .experimental(Map.of("feature", "test")) .roots(true) @@ -391,18 +485,14 @@ public abstract class AbstractMcpAsyncClientTests { Function> samplingHandler = request -> Mono .just(CreateMessageResult.builder().message("test").model("test-model").build()); - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .capabilities(capabilities) - .sampling(samplingHandler) - .build(); - StepVerifier.create(client.initialize()).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.capabilities()).isNotNull(); - }).verifyComplete(); + withClient(createMcpTransport(), builder -> builder.capabilities(capabilities).sampling(samplingHandler), + client -> - StepVerifier.create(client.closeGracefully()).verifyComplete(); + StepVerifier.create(client.initialize()).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.capabilities()).isNotNull(); + }).verifyComplete()); } // --------------------------------------- @@ -411,43 +501,52 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testLoggingLevelsWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before setting logging level")) - .verify(); + withClient(createMcpTransport(), + mcpAsyncClient -> StepVerifier + .withVirtualTime(() -> mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before setting logging level")) + .verify()); } @Test void testLoggingLevels() { - Mono testAllLevels = mcpAsyncClient.initialize().then(Mono.defer(() -> { - Mono chain = Mono.empty(); - for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { - chain = chain.then(mcpAsyncClient.setLoggingLevel(level)); - } - return chain; - })); + withClient(createMcpTransport(), mcpAsyncClient -> { + Mono testAllLevels = mcpAsyncClient.initialize().then(Mono.defer(() -> { + Mono chain = Mono.empty(); + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + chain = chain.then(mcpAsyncClient.setLoggingLevel(level)); + } + return chain; + })); - StepVerifier.create(testAllLevels).verifyComplete(); + StepVerifier.create(testAllLevels).verifyComplete(); + }); } @Test void testLoggingConsumer() { AtomicBoolean logReceived = new AtomicBoolean(false); - var transport = createMcpTransport(); - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))) - .build(); + withClient(createMcpTransport(), + builder -> builder.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))), + client -> { + StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); + StepVerifier.create(client.closeGracefully()).verifyComplete(); + + }); - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test void testLoggingWithNullNotification() { - StepVerifier.create(mcpAsyncClient.setLoggingLevel(null)) - .expectErrorMatches(error -> error.getMessage().contains("Logging level must not be null")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.setLoggingLevel(null)) + .expectErrorMatches(error -> error.getMessage().contains("Logging level must not be null")) + .verify(); + }); } } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 0f83e31..032f868 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -7,6 +7,9 @@ package io.modelcontextprotocol.client; import java.time.Duration; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; import io.modelcontextprotocol.spec.ClientMcpTransport; import io.modelcontextprotocol.spec.McpError; @@ -27,6 +30,10 @@ import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -40,12 +47,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; */ public abstract class AbstractMcpSyncClientTests { - private McpSyncClient mcpSyncClient; - private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; - protected ClientMcpTransport mcpTransport; - abstract protected ClientMcpTransport createMcpTransport(); protected void onStart() { @@ -62,254 +65,322 @@ public abstract class AbstractMcpSyncClientTests { return Duration.ofSeconds(2); } + McpSyncClient client(ClientMcpTransport transport) { + return client(transport, Function.identity()); + } + + McpSyncClient client(ClientMcpTransport transport, Function customizer) { + AtomicReference client = new AtomicReference<>(); + + assertThatCode(() -> { + McpClient.SyncSpec builder = McpClient.sync(transport) + .requestTimeout(getRequestTimeout()) + .initializationTimeout(getInitializationTimeout()) + .capabilities(ClientCapabilities.builder().roots(true).build()); + builder = customizer.apply(builder); + client.set(builder.build()); + }).doesNotThrowAnyException(); + + return client.get(); + } + + void withClient(ClientMcpTransport transport, Consumer c) { + withClient(transport, Function.identity(), c); + } + + void withClient(ClientMcpTransport transport, Function customizer, + Consumer c) { + var client = client(transport, customizer); + try { + c.accept(client); + } + finally { + assertThat(client.closeGracefully()).isTrue(); + } + } + @BeforeEach void setUp() { onStart(); - this.mcpTransport = createMcpTransport(); - assertThatCode(() -> { - mcpSyncClient = McpClient.sync(mcpTransport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .capabilities(ClientCapabilities.builder().roots(true).build()) - .build(); - }).doesNotThrowAnyException(); } @AfterEach void tearDown() { - if (mcpSyncClient != null) { - assertThatCode(() -> mcpSyncClient.close()).doesNotThrowAnyException(); - } onClose(); } + static final Object DUMMY_RETURN_VALUE = new Object(); + + void verifyNotificationTimesOut(Consumer operation, String action) { + verifyCallTimesOut(client -> { + operation.accept(client); + return DUMMY_RETURN_VALUE; + }, action); + } + + void verifyCallTimesOut(Function operation, String action) { + withClient(createMcpTransport(), mcpSyncClient -> { + // This scheduler is not replaced by virtual time scheduler + Scheduler customScheduler = Schedulers.newBoundedElastic(1, 1, "actualBoundedElastic"); + + StepVerifier.withVirtualTime(() -> Mono.fromSupplier(() -> operation.apply(mcpSyncClient)) + // offload the blocking call to the real scheduler + .subscribeOn(customScheduler)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before " + action)) + .verify(); + + customScheduler.dispose(); + }); + } + @Test void testConstructorWithInvalidArguments() { assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build()) + assertThatThrownBy(() -> McpClient.sync(createMcpTransport()).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } @Test void testListToolsWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.listTools(null)).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing tools"); + verifyCallTimesOut(client -> client.listTools(null), "listing tools"); } @Test void testListTools() { - mcpSyncClient.initialize(); - ListToolsResult tools = mcpSyncClient.listTools(null); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + ListToolsResult tools = mcpSyncClient.listTools(null); - assertThat(tools).isNotNull().satisfies(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); + assertThat(tools).isNotNull().satisfies(result -> { + assertThat(result.tools()).isNotNull().isNotEmpty(); - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); + Tool firstTool = result.tools().get(0); + assertThat(firstTool.name()).isNotNull(); + assertThat(firstTool.description()).isNotNull(); + }); }); } @Test void testCallToolsWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4)))) - .isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before calling tools"); + verifyCallTimesOut(client -> client.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))), + "calling tools"); } @Test void testCallTools() { - mcpSyncClient.initialize(); - CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); - assertThat(toolResult).isNotNull().satisfies(result -> { + assertThat(toolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).hasSize(1); + assertThat(result.content()).hasSize(1); - TextContent content = (TextContent) result.content().get(0); + TextContent content = (TextContent) result.content().get(0); - assertThat(content).isNotNull(); - assertThat(content.text()).isNotNull(); - assertThat(content.text()).contains("7"); + assertThat(content).isNotNull(); + assertThat(content.text()).isNotNull(); + assertThat(content.text()).contains("7"); + }); }); } @Test void testPingWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.ping()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before pinging the server"); + verifyCallTimesOut(client -> client.ping(), "pinging the server"); } @Test void testPing() { - mcpSyncClient.initialize(); - assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); + }); } @Test void testCallToolWithoutInitialization() { CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - - assertThatThrownBy(() -> mcpSyncClient.callTool(callToolRequest)).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before calling tools"); + verifyCallTimesOut(client -> client.callTool(callToolRequest), "calling tools"); } @Test void testCallTool() { - mcpSyncClient.initialize(); - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); + CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); + assertThat(callToolResult).isNotNull().satisfies(result -> { + assertThat(result.content()).isNotNull(); + assertThat(result.isError()).isNull(); + }); }); } @Test void testCallToolWithInvalidTool() { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); + withClient(createMcpTransport(), mcpSyncClient -> { + CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); - assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); + assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); + }); } @Test void testRootsListChangedWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.rootsListChangedNotification()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before sending roots list changed notification"); + verifyNotificationTimesOut(client -> client.rootsListChangedNotification(), + "sending roots list changed notification"); } @Test void testRootsListChanged() { - mcpSyncClient.initialize(); - assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException(); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException(); + }); } @Test void testListResourcesWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.listResources(null)).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resources"); + verifyCallTimesOut(client -> client.listResources(null), "listing resources"); } @Test void testListResources() { - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(null); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + ListResourcesResult resources = mcpSyncClient.listResources(null); - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); + assertThat(resources).isNotNull().satisfies(result -> { + assertThat(result.resources()).isNotNull(); - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } + if (!result.resources().isEmpty()) { + Resource firstResource = result.resources().get(0); + assertThat(firstResource.uri()).isNotNull(); + assertThat(firstResource.name()).isNotNull(); + } + }); }); } @Test void testClientSessionState() { - assertThat(mcpSyncClient).isNotNull(); + withClient(createMcpTransport(), mcpSyncClient -> { + assertThat(mcpSyncClient).isNotNull(); + }); } @Test void testInitializeWithRootsListProviders() { - var transport = createMcpTransport(); + withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), + mcpSyncClient -> { - var client = McpClient.sync(transport) - .requestTimeout(getRequestTimeout()) - .roots(new Root("file:///test/path", "test-root")) - .build(); - - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); + assertThatCode(() -> { + mcpSyncClient.initialize(); + mcpSyncClient.close(); + }).doesNotThrowAnyException(); + }); } @Test void testAddRoot() { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); - assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException(); + withClient(createMcpTransport(), mcpSyncClient -> { + Root newRoot = new Root("file:///new/test/path", "new-test-root"); + assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException(); + }); } @Test void testAddRootWithNullValue() { - assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null"); + withClient(createMcpTransport(), mcpSyncClient -> { + assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null"); + }); } @Test void testRemoveRoot() { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); - assertThatCode(() -> { - mcpSyncClient.addRoot(root); - mcpSyncClient.removeRoot(root.uri()); - }).doesNotThrowAnyException(); + withClient(createMcpTransport(), mcpSyncClient -> { + Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + assertThatCode(() -> { + mcpSyncClient.addRoot(root); + mcpSyncClient.removeRoot(root.uri()); + }).doesNotThrowAnyException(); + }); } @Test void testRemoveNonExistentRoot() { - assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri")) - .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); + withClient(createMcpTransport(), mcpSyncClient -> { + assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri")) + .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); + }); } @Test void testReadResourceWithoutInitialization() { - assertThatThrownBy(() -> { - Resource resource = new Resource("test://uri", "Test Resource", null, null, null); - mcpSyncClient.readResource(resource); - }).isInstanceOf(McpError.class).hasMessage("Client must be initialized before reading resources"); + Resource resource = new Resource("test://uri", "Test Resource", null, null, null); + verifyCallTimesOut(client -> client.readResource(resource), "reading resources"); } @Test void testReadResource() { - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(null); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + ListResourcesResult resources = mcpSyncClient.listResources(null); - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); - ReadResourceResult result = mcpSyncClient.readResource(firstResource); + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); + ReadResourceResult result = mcpSyncClient.readResource(firstResource); - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull(); - } + assertThat(result).isNotNull(); + assertThat(result.contents()).isNotNull(); + } + }); } @Test void testListResourceTemplatesWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.listResourceTemplates(null)).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resource templates"); + verifyCallTimesOut(client -> client.listResourceTemplates(null), "listing resource templates"); } @Test void testListResourceTemplates() { - mcpSyncClient.initialize(); - ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null); - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).isNotNull(); + }); } // @Test void testResourceSubscription() { - ListResourcesResult resources = mcpSyncClient.listResources(null); + withClient(createMcpTransport(), mcpSyncClient -> { + ListResourcesResult resources = mcpSyncClient.listResources(null); - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); - // Test subscribe - assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) - .doesNotThrowAnyException(); + // Test subscribe + assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) + .doesNotThrowAnyException(); - // Test unsubscribe - assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) - .doesNotThrowAnyException(); - } + // Test unsubscribe + assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) + .doesNotThrowAnyException(); + } + }); } @Test @@ -318,18 +389,17 @@ public abstract class AbstractMcpSyncClientTests { AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); - var transport = createMcpTransport(); - var client = McpClient.sync(transport) - .requestTimeout(getRequestTimeout()) - .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) - .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) - .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) - .build(); + withClient(createMcpTransport(), + builder -> builder.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) + .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) + .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)), + client -> { - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); + assertThatCode(() -> { + client.initialize(); + client.close(); + }).doesNotThrowAnyException(); + }); } // --------------------------------------- @@ -338,40 +408,37 @@ public abstract class AbstractMcpSyncClientTests { @Test void testLoggingLevelsWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) - .isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before setting logging level"); + verifyNotificationTimesOut(client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), + "setting logging level"); } @Test void testLoggingLevels() { - mcpSyncClient.initialize(); - // Test all logging levels - for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { - assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException(); - } + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + // Test all logging levels + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException(); + } + }); } @Test void testLoggingConsumer() { AtomicBoolean logReceived = new AtomicBoolean(false); - var transport = createMcpTransport(); - - var client = McpClient.sync(transport) - .requestTimeout(getRequestTimeout()) - .loggingConsumer(notification -> logReceived.set(true)) - .build(); - - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); + withClient(createMcpTransport(), builder -> builder.requestTimeout(getRequestTimeout()) + .loggingConsumer(notification -> logReceived.set(true)), client -> { + assertThatCode(() -> { + client.initialize(); + client.close(); + }).doesNotThrowAnyException(); + }); } @Test void testLoggingWithNullNotification() { - assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null)) - .hasMessageContaining("Logging level must not be null"); + withClient(createMcpTransport(), mcpSyncClient -> assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null)) + .hasMessageContaining("Logging level must not be null")); } } diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java b/mcp/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java index 614c651..d35db3f 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java @@ -353,14 +353,15 @@ public class StdioClientTransport implements ClientMcpTransport { // Give a short time for any pending messages to be processed return Mono.delay(Duration.ofMillis(100)); - })).then(Mono.fromFuture(() -> { + })).then(Mono.defer(() -> { logger.debug("Sending TERM to process"); if (this.process != null) { this.process.destroy(); - return process.onExit(); + return Mono.fromFuture(process.onExit()); } else { - return CompletableFuture.failedFuture(new RuntimeException("Process not started")); + logger.warn("Process not started"); + return Mono.empty(); } })).doOnNext(process -> { if (process.exitValue() != 0) { diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpSession.java b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpSession.java index e2d354f..46aefaf 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpSession.java +++ b/mcp/src/main/java/io/modelcontextprotocol/spec/DefaultMcpSession.java @@ -270,8 +270,10 @@ public class DefaultMcpSession implements McpSession { */ @Override public Mono closeGracefully() { - this.connection.dispose(); - return transport.closeGracefully(); + return Mono.defer(() -> { + this.connection.dispose(); + return transport.closeGracefully(); + }); } /** diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 39bc499..7203885 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -6,8 +6,12 @@ package io.modelcontextprotocol.client; import java.time.Duration; import java.util.Map; +import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; import io.modelcontextprotocol.spec.ClientMcpTransport; import io.modelcontextprotocol.spec.McpError; @@ -45,10 +49,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; // KEEP IN SYNC with the class in mcp-test module public abstract class AbstractMcpAsyncClientTests { - private McpAsyncClient mcpAsyncClient; - - protected ClientMcpTransport mcpTransport; - private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!"; abstract protected ClientMcpTransport createMcpTransport(); @@ -67,285 +67,326 @@ public abstract class AbstractMcpAsyncClientTests { return Duration.ofSeconds(2); } + McpAsyncClient client(ClientMcpTransport transport) { + return client(transport, Function.identity()); + } + + McpAsyncClient client(ClientMcpTransport transport, Function customizer) { + AtomicReference client = new AtomicReference<>(); + + assertThatCode(() -> { + McpClient.AsyncSpec builder = McpClient.async(transport) + .requestTimeout(getRequestTimeout()) + .initializationTimeout(getInitializationTimeout()) + .capabilities(ClientCapabilities.builder().roots(true).build()); + builder = customizer.apply(builder); + client.set(builder.build()); + }).doesNotThrowAnyException(); + + return client.get(); + } + + void withClient(ClientMcpTransport transport, Consumer c) { + withClient(transport, Function.identity(), c); + } + + void withClient(ClientMcpTransport transport, Function customizer, + Consumer c) { + var client = client(transport, customizer); + try { + c.accept(client); + } + finally { + StepVerifier.create(client.closeGracefully()).expectComplete().verify(Duration.ofSeconds(10)); + } + } + @BeforeEach void setUp() { onStart(); - this.mcpTransport = createMcpTransport(); - - assertThatCode(() -> { - mcpAsyncClient = McpClient.async(mcpTransport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .capabilities(ClientCapabilities.builder().roots(true).build()) - .build(); - }).doesNotThrowAnyException(); } @AfterEach void tearDown() { - if (mcpAsyncClient != null) { - StepVerifier.create(mcpAsyncClient.closeGracefully()).verifyComplete(); - } onClose(); } + void verifyInitializationTimeout(Function> operation, String action) { + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.withVirtualTime(() -> operation.apply(mcpAsyncClient)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before " + action)) + .verify(); + }); + } + @Test void testConstructorWithInvalidArguments() { assertThatThrownBy(() -> McpClient.async(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.async(mcpTransport).requestTimeout(null).build()) + assertThatThrownBy(() -> McpClient.async(createMcpTransport()).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } @Test void testListToolsWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.listTools(null)).expectErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing tools"); - }).verify(); + verifyInitializationTimeout(client -> client.listTools(null), "listing tools"); } @Test void testListTools() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools(null))) - .consumeNextWith(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools(null))) + .consumeNextWith(result -> { + assertThat(result.tools()).isNotNull().isNotEmpty(); - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }) - .verifyComplete(); + Tool firstTool = result.tools().get(0); + assertThat(firstTool.name()).isNotNull(); + assertThat(firstTool.description()).isNotNull(); + }) + .verifyComplete(); + }); } @Test void testPingWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.ping()) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before pinging the server")) - .verify(); + verifyInitializationTimeout(client -> client.ping(), "pinging the server"); } @Test void testPing() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())).consumeNextWith(callToolResult -> { - }).verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())) + .expectNextCount(1) + .verifyComplete(); + }); } @Test void testCallToolWithoutInitialization() { CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - - StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before calling tools")) - .verify(); + verifyInitializationTimeout(client -> client.callTool(callToolRequest), "calling tools"); } @Test void testCallTool() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); + withClient(createMcpTransport(), mcpAsyncClient -> { + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest))) - .consumeNextWith(callToolResult -> { - assertThat(callToolResult).isNotNull(); - assertThat(callToolResult.content()).isNotNull(); - assertThat(callToolResult.isError()).isNull(); - }) - .verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest))) + .consumeNextWith(callToolResult -> { + assertThat(callToolResult).isNotNull().satisfies(result -> { + assertThat(result.content()).isNotNull(); + assertThat(result.isError()).isNull(); + }); + }) + .verifyComplete(); + }); } @Test void testCallToolWithInvalidTool() { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE)); + withClient(createMcpTransport(), mcpAsyncClient -> { + CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", + Map.of("message", ECHO_TEST_MESSAGE)); - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest))) - .expectError(Exception.class) - .verify(); + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest))) + .consumeErrorWith( + e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Unknown tool: nonexistent_tool")) + .verify(); + }); } @Test void testListResourcesWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.listResources(null)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before listing resources")) - .verify(); + verifyInitializationTimeout(client -> client.listResources(null), "listing resources"); } @Test void testListResources() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources(null))) - .consumeNextWith(resources -> { - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources(null))) + .consumeNextWith(resources -> { + assertThat(resources).isNotNull().satisfies(result -> { + assertThat(result.resources()).isNotNull(); - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }) - .verifyComplete(); + if (!result.resources().isEmpty()) { + Resource firstResource = result.resources().get(0); + assertThat(firstResource.uri()).isNotNull(); + assertThat(firstResource.name()).isNotNull(); + } + }); + }) + .verifyComplete(); + }); } @Test void testMcpAsyncClientState() { - assertThat(mcpAsyncClient).isNotNull(); + withClient(createMcpTransport(), mcpAsyncClient -> { + assertThat(mcpAsyncClient).isNotNull(); + }); } @Test void testListPromptsWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.listPrompts(null)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before listing prompts")) - .verify(); + verifyInitializationTimeout(client -> client.listPrompts(null), "listing " + "prompts"); } @Test void testListPrompts() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts(null))) - .consumeNextWith(prompts -> { - assertThat(prompts).isNotNull().satisfies(result -> { - assertThat(result.prompts()).isNotNull(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts(null))) + .consumeNextWith(prompts -> { + assertThat(prompts).isNotNull().satisfies(result -> { + assertThat(result.prompts()).isNotNull(); - if (!result.prompts().isEmpty()) { - Prompt firstPrompt = result.prompts().get(0); - assertThat(firstPrompt.name()).isNotNull(); - assertThat(firstPrompt.description()).isNotNull(); - } - }); - }) - .verifyComplete(); + if (!result.prompts().isEmpty()) { + Prompt firstPrompt = result.prompts().get(0); + assertThat(firstPrompt.name()).isNotNull(); + assertThat(firstPrompt.description()).isNotNull(); + } + }); + }) + .verifyComplete(); + }); } @Test void testGetPromptWithoutInitialization() { GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - - StepVerifier.create(mcpAsyncClient.getPrompt(request)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before getting prompts")) - .verify(); + verifyInitializationTimeout(client -> client.getPrompt(request), "getting " + "prompts"); } @Test void testGetPrompt() { - GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.getPrompt(request))) - .consumeNextWith(prompt -> { - assertThat(prompt).isNotNull().satisfies(result -> { - assertThat(result.messages()).isNotEmpty(); - assertThat(result.messages()).hasSize(1); - }); - }) - .verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier + .create(mcpAsyncClient.initialize() + .then(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of())))) + .consumeNextWith(prompt -> { + assertThat(prompt).isNotNull().satisfies(result -> { + assertThat(result.messages()).isNotEmpty(); + assertThat(result.messages()).hasSize(1); + }); + }) + .verifyComplete(); + }); } @Test void testRootsListChangedWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.rootsListChangedNotification()) - .expectErrorMatches(error -> error instanceof McpError && error.getMessage() - .equals("Client must be initialized before sending roots list changed notification")) - .verify(); + verifyInitializationTimeout(client -> client.rootsListChangedNotification(), + "sending roots list changed notification"); } @Test void testRootsListChanged() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.rootsListChangedNotification())) - .verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.rootsListChangedNotification())) + .verifyComplete(); + }); } @Test void testInitializeWithRootsListProviders() { - var transport = createMcpTransport(); - - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .roots(new Root("file:///test/path", "test-root")) - .build(); - - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); + withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), + client -> { + StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); + }); } @Test void testAddRoot() { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); - - StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + Root newRoot = new Root("file:///new/test/path", "new-test-root"); + StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete(); + }); } @Test void testAddRootWithNullValue() { - StepVerifier.create(mcpAsyncClient.addRoot(null)) - .expectErrorMatches(error -> error.getMessage().contains("Root must not be null")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.addRoot(null)) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Root must not be null")) + .verify(); + }); } @Test void testRemoveRoot() { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + withClient(createMcpTransport(), mcpAsyncClient -> { + Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + StepVerifier.create(mcpAsyncClient.addRoot(root)).verifyComplete(); - StepVerifier.create(mcpAsyncClient.addRoot(root).then(mcpAsyncClient.removeRoot(root.uri()))).verifyComplete(); + StepVerifier.create(mcpAsyncClient.removeRoot(root.uri())).verifyComplete(); + }); } @Test void testRemoveNonExistentRoot() { - StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri")) - .expectErrorMatches(error -> error.getMessage().contains("Root with uri 'nonexistent-uri' not found")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri")) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Root with uri 'nonexistent-uri' not found")) + .verify(); + }); } @Test @Disabled void testReadResource() { - StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); - StepVerifier.create(mcpAsyncClient.readResource(firstResource)).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull(); - }).verifyComplete(); - } - }).verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); + StepVerifier.create(mcpAsyncClient.readResource(firstResource)).consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).isNotNull(); + }).verifyComplete(); + } + }).verifyComplete(); + }); } @Test void testListResourceTemplatesWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.listResourceTemplates()) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before listing resource templates")) - .verify(); + verifyInitializationTimeout(client -> client.listResourceTemplates(), "listing resource templates"); } @Test void testListResourceTemplates() { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) - .consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }) - .verifyComplete(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) + .consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).isNotNull(); + }) + .verifyComplete(); + }); } // @Test void testResourceSubscription() { - StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); - // Test subscribe - StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) - .verifyComplete(); + // Test subscribe + StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) + .verifyComplete(); - // Test unsubscribe - StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) - .verifyComplete(); - } - }).verifyComplete(); + // Test unsubscribe + StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) + .verifyComplete(); + } + }).verifyComplete(); + }); } @Test @@ -354,36 +395,44 @@ public abstract class AbstractMcpAsyncClientTests { AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); - var transport = createMcpTransport(); - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) - .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) - .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) - .build(); + withClient(createMcpTransport(), + builder -> builder + .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) + .resourcesChangeConsumer( + resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) + .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))), + mcpAsyncClient -> { - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); + var transport = createMcpTransport(); + var client = McpClient.async(transport) + .requestTimeout(getRequestTimeout()) + .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) + .resourcesChangeConsumer( + resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) + .promptsChangeConsumer( + prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) + .build(); + + StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); + }); } @Test void testInitializeWithSamplingCapability() { - var transport = createMcpTransport(); - - var capabilities = ClientCapabilities.builder().sampling().build(); - - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .capabilities(capabilities) - .sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build())) + ClientCapabilities capabilities = ClientCapabilities.builder().sampling().build(); + CreateMessageResult createMessageResult = CreateMessageResult.builder() + .message("test") + .model("test-model") .build(); - - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); + withClient(createMcpTransport(), + builder -> builder.capabilities(capabilities).sampling(request -> Mono.just(createMessageResult)), + client -> { + StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); + }); } @Test void testInitializeWithAllCapabilities() { - var transport = createMcpTransport(); - var capabilities = ClientCapabilities.builder() .experimental(Map.of("feature", "test")) .roots(true) @@ -392,18 +441,14 @@ public abstract class AbstractMcpAsyncClientTests { Function> samplingHandler = request -> Mono .just(CreateMessageResult.builder().message("test").model("test-model").build()); - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .capabilities(capabilities) - .sampling(samplingHandler) - .build(); - StepVerifier.create(client.initialize()).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.capabilities()).isNotNull(); - }).verifyComplete(); + withClient(createMcpTransport(), builder -> builder.capabilities(capabilities).sampling(samplingHandler), + client -> - StepVerifier.create(client.closeGracefully()).verifyComplete(); + StepVerifier.create(client.initialize()).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.capabilities()).isNotNull(); + }).verifyComplete()); } // --------------------------------------- @@ -412,43 +457,46 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testLoggingLevelsWithoutInitialization() { - StepVerifier.create(mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) - .expectErrorMatches(error -> error instanceof McpError - && error.getMessage().equals("Client must be initialized before setting logging level")) - .verify(); + verifyInitializationTimeout(client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), + "setting logging level"); } @Test void testLoggingLevels() { - Mono testAllLevels = mcpAsyncClient.initialize().then(Mono.defer(() -> { - Mono chain = Mono.empty(); - for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { - chain = chain.then(mcpAsyncClient.setLoggingLevel(level)); - } - return chain; - })); + withClient(createMcpTransport(), mcpAsyncClient -> { + Mono testAllLevels = mcpAsyncClient.initialize().then(Mono.defer(() -> { + Mono chain = Mono.empty(); + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + chain = chain.then(mcpAsyncClient.setLoggingLevel(level)); + } + return chain; + })); - StepVerifier.create(testAllLevels).verifyComplete(); + StepVerifier.create(testAllLevels).verifyComplete(); + }); } @Test void testLoggingConsumer() { AtomicBoolean logReceived = new AtomicBoolean(false); - var transport = createMcpTransport(); - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))) - .build(); + withClient(createMcpTransport(), + builder -> builder.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))), + client -> { + StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); + StepVerifier.create(client.closeGracefully()).verifyComplete(); + + }); - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); } @Test void testLoggingWithNullNotification() { - StepVerifier.create(mcpAsyncClient.setLoggingLevel(null)) - .expectErrorMatches(error -> error.getMessage().contains("Logging level must not be null")) - .verify(); + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.create(mcpAsyncClient.setLoggingLevel(null)) + .expectErrorMatches(error -> error.getMessage().contains("Logging level must not be null")) + .verify(); + }); } } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 52a0138..1c042bf 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -7,6 +7,9 @@ package io.modelcontextprotocol.client; import java.time.Duration; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; import io.modelcontextprotocol.spec.ClientMcpTransport; import io.modelcontextprotocol.spec.McpError; @@ -27,6 +30,10 @@ import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -41,12 +48,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; // KEEP IN SYNC with the class in mcp-test module public abstract class AbstractMcpSyncClientTests { - private McpSyncClient mcpSyncClient; - private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; - protected ClientMcpTransport mcpTransport; - abstract protected ClientMcpTransport createMcpTransport(); protected void onStart() { @@ -63,254 +66,322 @@ public abstract class AbstractMcpSyncClientTests { return Duration.ofSeconds(2); } + McpSyncClient client(ClientMcpTransport transport) { + return client(transport, Function.identity()); + } + + McpSyncClient client(ClientMcpTransport transport, Function customizer) { + AtomicReference client = new AtomicReference<>(); + + assertThatCode(() -> { + McpClient.SyncSpec builder = McpClient.sync(transport) + .requestTimeout(getRequestTimeout()) + .initializationTimeout(getInitializationTimeout()) + .capabilities(ClientCapabilities.builder().roots(true).build()); + builder = customizer.apply(builder); + client.set(builder.build()); + }).doesNotThrowAnyException(); + + return client.get(); + } + + void withClient(ClientMcpTransport transport, Consumer c) { + withClient(transport, Function.identity(), c); + } + + void withClient(ClientMcpTransport transport, Function customizer, + Consumer c) { + var client = client(transport, customizer); + try { + c.accept(client); + } + finally { + assertThat(client.closeGracefully()).isTrue(); + } + } + @BeforeEach void setUp() { onStart(); - this.mcpTransport = createMcpTransport(); - assertThatCode(() -> { - mcpSyncClient = McpClient.sync(mcpTransport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .capabilities(ClientCapabilities.builder().roots(true).build()) - .build(); - }).doesNotThrowAnyException(); } @AfterEach void tearDown() { - if (mcpSyncClient != null) { - assertThatCode(() -> mcpSyncClient.close()).doesNotThrowAnyException(); - } onClose(); } + static final Object DUMMY_RETURN_VALUE = new Object(); + + void verifyNotificationTimesOut(Consumer operation, String action) { + verifyCallTimesOut(client -> { + operation.accept(client); + return DUMMY_RETURN_VALUE; + }, action); + } + + void verifyCallTimesOut(Function operation, String action) { + withClient(createMcpTransport(), mcpSyncClient -> { + // This scheduler is not replaced by virtual time scheduler + Scheduler customScheduler = Schedulers.newBoundedElastic(1, 1, "actualBoundedElastic"); + + StepVerifier.withVirtualTime(() -> Mono.fromSupplier(() -> operation.apply(mcpSyncClient)) + // offload the blocking call to the real scheduler + .subscribeOn(customScheduler)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before " + action)) + .verify(); + + customScheduler.dispose(); + }); + } + @Test void testConstructorWithInvalidArguments() { assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build()) + assertThatThrownBy(() -> McpClient.sync(createMcpTransport()).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } @Test void testListToolsWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.listTools(null)).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing tools"); + verifyCallTimesOut(client -> client.listTools(null), "listing tools"); } @Test void testListTools() { - mcpSyncClient.initialize(); - ListToolsResult tools = mcpSyncClient.listTools(null); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + ListToolsResult tools = mcpSyncClient.listTools(null); - assertThat(tools).isNotNull().satisfies(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); + assertThat(tools).isNotNull().satisfies(result -> { + assertThat(result.tools()).isNotNull().isNotEmpty(); - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); + Tool firstTool = result.tools().get(0); + assertThat(firstTool.name()).isNotNull(); + assertThat(firstTool.description()).isNotNull(); + }); }); } @Test void testCallToolsWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4)))) - .isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before calling tools"); + verifyCallTimesOut(client -> client.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))), + "calling tools"); } @Test void testCallTools() { - mcpSyncClient.initialize(); - CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); - assertThat(toolResult).isNotNull().satisfies(result -> { + assertThat(toolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).hasSize(1); + assertThat(result.content()).hasSize(1); - TextContent content = (TextContent) result.content().get(0); + TextContent content = (TextContent) result.content().get(0); - assertThat(content).isNotNull(); - assertThat(content.text()).isNotNull(); - assertThat(content.text()).contains("7"); + assertThat(content).isNotNull(); + assertThat(content.text()).isNotNull(); + assertThat(content.text()).contains("7"); + }); }); } @Test void testPingWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.ping()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before pinging the server"); + verifyCallTimesOut(client -> client.ping(), "pinging the server"); } @Test void testPing() { - mcpSyncClient.initialize(); - assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); + }); } @Test void testCallToolWithoutInitialization() { CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - - assertThatThrownBy(() -> mcpSyncClient.callTool(callToolRequest)).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before calling tools"); + verifyCallTimesOut(client -> client.callTool(callToolRequest), "calling tools"); } @Test void testCallTool() { - mcpSyncClient.initialize(); - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); + CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); + assertThat(callToolResult).isNotNull().satisfies(result -> { + assertThat(result.content()).isNotNull(); + assertThat(result.isError()).isNull(); + }); }); } @Test void testCallToolWithInvalidTool() { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); + withClient(createMcpTransport(), mcpSyncClient -> { + CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); - assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); + assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); + }); } @Test void testRootsListChangedWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.rootsListChangedNotification()).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before sending roots list changed notification"); + verifyNotificationTimesOut(client -> client.rootsListChangedNotification(), + "sending roots list changed notification"); } @Test void testRootsListChanged() { - mcpSyncClient.initialize(); - assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException(); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException(); + }); } @Test void testListResourcesWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.listResources(null)).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resources"); + verifyCallTimesOut(client -> client.listResources(null), "listing resources"); } @Test void testListResources() { - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(null); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + ListResourcesResult resources = mcpSyncClient.listResources(null); - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); + assertThat(resources).isNotNull().satisfies(result -> { + assertThat(result.resources()).isNotNull(); - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } + if (!result.resources().isEmpty()) { + Resource firstResource = result.resources().get(0); + assertThat(firstResource.uri()).isNotNull(); + assertThat(firstResource.name()).isNotNull(); + } + }); }); } @Test void testClientSessionState() { - assertThat(mcpSyncClient).isNotNull(); + withClient(createMcpTransport(), mcpSyncClient -> { + assertThat(mcpSyncClient).isNotNull(); + }); } @Test void testInitializeWithRootsListProviders() { - var transport = createMcpTransport(); + withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), + mcpSyncClient -> { - var client = McpClient.sync(transport) - .requestTimeout(getRequestTimeout()) - .roots(new Root("file:///test/path", "test-root")) - .build(); - - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); + assertThatCode(() -> { + mcpSyncClient.initialize(); + mcpSyncClient.close(); + }).doesNotThrowAnyException(); + }); } @Test void testAddRoot() { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); - assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException(); + withClient(createMcpTransport(), mcpSyncClient -> { + Root newRoot = new Root("file:///new/test/path", "new-test-root"); + assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException(); + }); } @Test void testAddRootWithNullValue() { - assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null"); + withClient(createMcpTransport(), mcpSyncClient -> { + assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null"); + }); } @Test void testRemoveRoot() { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); - assertThatCode(() -> { - mcpSyncClient.addRoot(root); - mcpSyncClient.removeRoot(root.uri()); - }).doesNotThrowAnyException(); + withClient(createMcpTransport(), mcpSyncClient -> { + Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + assertThatCode(() -> { + mcpSyncClient.addRoot(root); + mcpSyncClient.removeRoot(root.uri()); + }).doesNotThrowAnyException(); + }); } @Test void testRemoveNonExistentRoot() { - assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri")) - .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); + withClient(createMcpTransport(), mcpSyncClient -> { + assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri")) + .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); + }); } @Test void testReadResourceWithoutInitialization() { - assertThatThrownBy(() -> { - Resource resource = new Resource("test://uri", "Test Resource", null, null, null); - mcpSyncClient.readResource(resource); - }).isInstanceOf(McpError.class).hasMessage("Client must be initialized before reading resources"); + Resource resource = new Resource("test://uri", "Test Resource", null, null, null); + verifyCallTimesOut(client -> client.readResource(resource), "reading resources"); } @Test void testReadResource() { - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(null); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + ListResourcesResult resources = mcpSyncClient.listResources(null); - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); - ReadResourceResult result = mcpSyncClient.readResource(firstResource); + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); + ReadResourceResult result = mcpSyncClient.readResource(firstResource); - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull(); - } + assertThat(result).isNotNull(); + assertThat(result.contents()).isNotNull(); + } + }); } @Test void testListResourceTemplatesWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.listResourceTemplates(null)).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resource templates"); + verifyCallTimesOut(client -> client.listResourceTemplates(null), "listing resource templates"); } @Test void testListResourceTemplates() { - mcpSyncClient.initialize(); - ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null); + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null); - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).isNotNull(); + }); } // @Test void testResourceSubscription() { - ListResourcesResult resources = mcpSyncClient.listResources(null); + withClient(createMcpTransport(), mcpSyncClient -> { + ListResourcesResult resources = mcpSyncClient.listResources(null); - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); - // Test subscribe - assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) - .doesNotThrowAnyException(); + // Test subscribe + assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) + .doesNotThrowAnyException(); - // Test unsubscribe - assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) - .doesNotThrowAnyException(); - } + // Test unsubscribe + assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) + .doesNotThrowAnyException(); + } + }); } @Test @@ -319,18 +390,17 @@ public abstract class AbstractMcpSyncClientTests { AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); - var transport = createMcpTransport(); - var client = McpClient.sync(transport) - .requestTimeout(getRequestTimeout()) - .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) - .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) - .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) - .build(); + withClient(createMcpTransport(), + builder -> builder.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) + .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) + .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)), + client -> { - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); + assertThatCode(() -> { + client.initialize(); + client.close(); + }).doesNotThrowAnyException(); + }); } // --------------------------------------- @@ -339,40 +409,37 @@ public abstract class AbstractMcpSyncClientTests { @Test void testLoggingLevelsWithoutInitialization() { - assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) - .isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before setting logging level"); + verifyNotificationTimesOut(client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), + "setting logging level"); } @Test void testLoggingLevels() { - mcpSyncClient.initialize(); - // Test all logging levels - for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { - assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException(); - } + withClient(createMcpTransport(), mcpSyncClient -> { + mcpSyncClient.initialize(); + // Test all logging levels + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException(); + } + }); } @Test void testLoggingConsumer() { AtomicBoolean logReceived = new AtomicBoolean(false); - var transport = createMcpTransport(); - - var client = McpClient.sync(transport) - .requestTimeout(getRequestTimeout()) - .loggingConsumer(notification -> logReceived.set(true)) - .build(); - - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); + withClient(createMcpTransport(), builder -> builder.requestTimeout(getRequestTimeout()) + .loggingConsumer(notification -> logReceived.set(true)), client -> { + assertThatCode(() -> { + client.initialize(); + client.close(); + }).doesNotThrowAnyException(); + }); } @Test void testLoggingWithNullNotification() { - assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null)) - .hasMessageContaining("Logging level must not be null"); + withClient(createMcpTransport(), mcpSyncClient -> assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null)) + .hasMessageContaining("Logging level must not be null")); } } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java index 6d759b4..ebf10b9 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java @@ -5,6 +5,8 @@ package io.modelcontextprotocol.client; import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import io.modelcontextprotocol.client.transport.ServerParameters; @@ -13,6 +15,7 @@ import io.modelcontextprotocol.spec.ClientMcpTransport; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; import static org.assertj.core.api.Assertions.assertThat; @@ -35,15 +38,26 @@ class StdioMcpSyncClientTests extends AbstractMcpSyncClientTests { } @Test - void customErrorHandlerShouldReceiveErrors() { + void customErrorHandlerShouldReceiveErrors() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); AtomicReference receivedError = new AtomicReference<>(); - ((StdioClientTransport) mcpTransport).setStdErrorHandler(error -> receivedError.set(error)); + ClientMcpTransport transport = createMcpTransport(); + StepVerifier.create(transport.connect(msg -> msg)).verifyComplete(); + + ((StdioClientTransport) transport).setStdErrorHandler(error -> { + receivedError.set(error); + latch.countDown(); + }); String errorMessage = "Test error"; - ((StdioClientTransport) mcpTransport).getErrorSink().emitNext(errorMessage, Sinks.EmitFailureHandler.FAIL_FAST); + ((StdioClientTransport) transport).getErrorSink().emitNext(errorMessage, Sinks.EmitFailureHandler.FAIL_FAST); + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(receivedError.get()).isNotNull().isEqualTo(errorMessage); + + StepVerifier.create(transport.closeGracefully()).expectComplete().verify(Duration.ofSeconds(5)); } protected Duration getInitializationTimeout() { From e996a5de1e72c11031d6a552af13f9d5dd95a427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Thu, 20 Mar 2025 09:03:08 +0100 Subject: [PATCH 022/393] Follow-up fix client tests reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk --- .../client/AbstractMcpAsyncClientTests.java | 15 +++------------ .../client/AbstractMcpSyncClientTests.java | 11 ++++++++--- .../client/AbstractMcpAsyncClientTests.java | 15 +++------------ .../client/AbstractMcpSyncClientTests.java | 11 ++++++++--- 4 files changed, 22 insertions(+), 30 deletions(-) diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 033139a..18ec06c 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -446,18 +446,9 @@ public abstract class AbstractMcpAsyncClientTests { resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))), mcpAsyncClient -> { - - var transport = createMcpTransport(); - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) - .resourcesChangeConsumer( - resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) - .promptsChangeConsumer( - prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) - .build(); - - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize()) + .expectNextMatches(Objects::nonNull) + .verifyComplete(); }); } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 032f868..191de23 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -119,15 +119,20 @@ public abstract class AbstractMcpSyncClientTests { }, action); } - void verifyCallTimesOut(Function operation, String action) { + void verifyCallTimesOut(Function blockingOperation, String action) { withClient(createMcpTransport(), mcpSyncClient -> { // This scheduler is not replaced by virtual time scheduler Scheduler customScheduler = Schedulers.newBoundedElastic(1, 1, "actualBoundedElastic"); - StepVerifier.withVirtualTime(() -> Mono.fromSupplier(() -> operation.apply(mcpSyncClient)) - // offload the blocking call to the real scheduler + StepVerifier.withVirtualTime(() -> Mono.fromSupplier(() -> blockingOperation.apply(mcpSyncClient)) + // Offload the blocking call to the real scheduler .subscribeOn(customScheduler)) .expectSubscription() + // This works without actually waiting but executes all the + // tasks pending execution on the VirtualTimeScheduler. + // It is possible to execute the blocking code from the operation + // because it is blocked on a dedicated Scheduler and the main + // flow is not blocked and uses the VirtualTimeScheduler. .thenAwait(getInitializationTimeout()) .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) .hasMessage("Client must be initialized before " + action)) diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 7203885..06a231e 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -402,18 +402,9 @@ public abstract class AbstractMcpAsyncClientTests { resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))), mcpAsyncClient -> { - - var transport = createMcpTransport(); - var client = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) - .resourcesChangeConsumer( - resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) - .promptsChangeConsumer( - prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))) - .build(); - - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); + StepVerifier.create(mcpAsyncClient.initialize()) + .expectNextMatches(Objects::nonNull) + .verifyComplete(); }); } diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 1c042bf..f4d8dbd 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -120,15 +120,20 @@ public abstract class AbstractMcpSyncClientTests { }, action); } - void verifyCallTimesOut(Function operation, String action) { + void verifyCallTimesOut(Function blockingOperation, String action) { withClient(createMcpTransport(), mcpSyncClient -> { // This scheduler is not replaced by virtual time scheduler Scheduler customScheduler = Schedulers.newBoundedElastic(1, 1, "actualBoundedElastic"); - StepVerifier.withVirtualTime(() -> Mono.fromSupplier(() -> operation.apply(mcpSyncClient)) - // offload the blocking call to the real scheduler + StepVerifier.withVirtualTime(() -> Mono.fromSupplier(() -> blockingOperation.apply(mcpSyncClient)) + // Offload the blocking call to the real scheduler .subscribeOn(customScheduler)) .expectSubscription() + // This works without actually waiting but executes all the + // tasks pending execution on the VirtualTimeScheduler. + // It is possible to execute the blocking code from the operation + // because it is blocked on a dedicated Scheduler and the main + // flow is not blocked and uses the VirtualTimeScheduler. .thenAwait(getInitializationTimeout()) .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) .hasMessage("Client must be initialized before " + action)) From 9c2b836e414ff11f3a57e925a85c433114df2a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Thu, 20 Mar 2025 09:36:56 +0100 Subject: [PATCH 023/393] Sync async client tests between mcp and mcp-test module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk --- .../client/AbstractMcpAsyncClientTests.java | 100 +++++------------- .../client/AbstractMcpAsyncClientTests.java | 1 - 2 files changed, 24 insertions(+), 77 deletions(-) diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 18ec06c..02aa23d 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -109,6 +109,17 @@ public abstract class AbstractMcpAsyncClientTests { onClose(); } + void verifyInitializationTimeout(Function> operation, String action) { + withClient(createMcpTransport(), mcpAsyncClient -> { + StepVerifier.withVirtualTime(() -> operation.apply(mcpAsyncClient)) + .expectSubscription() + .thenAwait(getInitializationTimeout()) + .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized before " + action)) + .verify(); + }); + } + @Test void testConstructorWithInvalidArguments() { assertThatThrownBy(() -> McpClient.async(null).build()).isInstanceOf(IllegalArgumentException.class) @@ -121,14 +132,7 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testListToolsWithoutInitialization() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.withVirtualTime(() -> mcpAsyncClient.listTools(null)) - .expectSubscription() - .thenAwait(getInitializationTimeout()) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing tools")) - .verify(); - }); + verifyInitializationTimeout(client -> client.listTools(null), "listing tools"); } @Test @@ -148,14 +152,7 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testPingWithoutInitialization() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.withVirtualTime(() -> mcpAsyncClient.ping()) - .expectSubscription() - .thenAwait(getInitializationTimeout()) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before pinging the " + "server")) - .verify(); - }); + verifyInitializationTimeout(client -> client.ping(), "pinging the server"); } @Test @@ -169,16 +166,8 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testCallToolWithoutInitialization() { - withClient(createMcpTransport(), mcpAsyncClient -> { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - - StepVerifier.withVirtualTime(() -> mcpAsyncClient.callTool(callToolRequest)) - .expectSubscription() - .thenAwait(getInitializationTimeout()) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before calling tools")) - .verify(); - }); + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); + verifyInitializationTimeout(client -> client.callTool(callToolRequest), "calling tools"); } @Test @@ -212,14 +201,7 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testListResourcesWithoutInitialization() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.withVirtualTime(() -> mcpAsyncClient.listResources(null)) - .expectSubscription() - .thenAwait(getInitializationTimeout()) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resources")) - .verify(); - }); + verifyInitializationTimeout(client -> client.listResources(null), "listing resources"); } @Test @@ -250,14 +232,7 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testListPromptsWithoutInitialization() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.withVirtualTime(() -> mcpAsyncClient.listPrompts(null)) - .expectSubscription() - .thenAwait(getInitializationTimeout()) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing prompts")) - .verify(); - }); + verifyInitializationTimeout(client -> client.listPrompts(null), "listing " + "prompts"); } @Test @@ -281,16 +256,8 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testGetPromptWithoutInitialization() { - withClient(createMcpTransport(), mcpAsyncClient -> { - GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - - StepVerifier.withVirtualTime(() -> mcpAsyncClient.getPrompt(request)) - .expectSubscription() - .thenAwait(getInitializationTimeout()) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before getting prompts")) - .verify(); - }); + GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); + verifyInitializationTimeout(client -> client.getPrompt(request), "getting " + "prompts"); } @Test @@ -311,14 +278,8 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testRootsListChangedWithoutInitialization() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.withVirtualTime(() -> mcpAsyncClient.rootsListChangedNotification()) - .expectSubscription() - .thenAwait(getInitializationTimeout()) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before sending roots list changed notification")) - .verify(); - }); + verifyInitializationTimeout(client -> client.rootsListChangedNotification(), + "sending roots list changed notification"); } @Test @@ -392,14 +353,7 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testListResourceTemplatesWithoutInitialization() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.withVirtualTime(() -> mcpAsyncClient.listResourceTemplates()) - .expectSubscription() - .thenAwait(getInitializationTimeout()) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before listing resource templates")) - .verify(); - }); + verifyInitializationTimeout(client -> client.listResourceTemplates(), "listing resource templates"); } @Test @@ -492,14 +446,8 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testLoggingLevelsWithoutInitialization() { - withClient(createMcpTransport(), - mcpAsyncClient -> StepVerifier - .withVirtualTime(() -> mcpAsyncClient.setLoggingLevel(McpSchema.LoggingLevel.DEBUG)) - .expectSubscription() - .thenAwait(getInitializationTimeout()) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized before setting logging level")) - .verify()); + verifyInitializationTimeout(client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), + "setting logging level"); } @Test diff --git a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index 06a231e..f7a0a49 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -11,7 +11,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.Supplier; import io.modelcontextprotocol.spec.ClientMcpTransport; import io.modelcontextprotocol.spec.McpError; From 34a733509d88054b64ff84eaf43d0fb1f47bd1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Thu, 20 Mar 2025 18:14:57 +0100 Subject: [PATCH 024/393] refactor: introduce session-based architecture for MCP server (#31) This commit introduces a major refactoring of the MCP Java SDK to implement a session-based architecture for server-side implementations. The changes improve the SDK's ability to handle multiple concurrent client connections and provide an API better aligned with the MCP specification. Key changes: - Introduce McpServerTransportProvider interface to manage client connections - Rename ClientMcpTransport to McpClientTransport and ServerMcpTransport to McpServerTransport - Add exchange objects (McpAsyncServerExchange, McpSyncServerExchange) for client interaction - Update handler signatures to include exchange parameter: (args) -> result to (exchange, args) -> result - Rename Registration classes to Specification classes - Update method names (e.g., rootsChangeConsumers to rootsChangeHandlers) - Deprecate old interfaces and classes for removal in 0.9.0 - Add migration guide (migration-0.8.0.md) Resolves #9, #15 Co-authored-by: Christian Tzolov Signed-off-by: Christian Tzolov --- .../transport/WebFluxSseClientTransport.java | 4 +- .../transport/WebFluxSseServerTransport.java | 17 +- .../WebFluxSseServerTransportProvider.java | 351 ++++ .../WebFluxSseIntegrationTests.java | 204 +- .../client/WebFluxSseMcpAsyncClientTests.java | 4 +- .../client/WebFluxSseMcpSyncClientTests.java | 4 +- ...bFluxSseMcpAsyncServerDeprecatedTests.java | 55 + .../server/WebFluxSseMcpAsyncServerTests.java | 15 +- ...ebFluxSseMcpSyncServerDeprecatecTests.java | 55 + .../server/WebFluxSseMcpSyncServerTests.java | 16 +- .../legacy/WebFluxSseIntegrationTests.java | 459 +++++ .../transport/WebMvcSseServerTransport.java | 5 +- .../WebMvcSseServerTransportProvider.java | 399 ++++ ...seAsyncServerTransportDeprecatedTests.java | 118 ++ .../WebMvcSseAsyncServerTransportTests.java | 24 +- .../WebMvcSseIntegrationDeprecatedTests.java | 508 +++++ .../server/WebMvcSseIntegrationTests.java | 195 +- ...SseSyncServerTransportDeprecatedTests.java | 118 ++ .../WebMvcSseSyncServerTransportTests.java | 23 +- .../MockMcpTransport.java | 8 +- .../client/AbstractMcpAsyncClientTests.java | 12 +- .../client/AbstractMcpSyncClientTests.java | 12 +- ...AbstractMcpAsyncServerDeprecatedTests.java | 465 +++++ .../server/AbstractMcpAsyncServerTests.java | 127 +- .../AbstractMcpSyncServerDeprecatedTests.java | 431 +++++ .../server/AbstractMcpSyncServerTests.java | 131 +- .../client/McpAsyncClient.java | 12 +- .../client/McpClient.java | 41 + .../client/McpSyncClient.java | 5 +- .../HttpClientSseClientTransport.java | 28 +- .../transport/StdioClientTransport.java | 5 +- .../server/McpAsyncServer.java | 1641 +++++++++++++---- .../server/McpAsyncServerExchange.java | 104 ++ .../server/McpServer.java | 995 +++++++++- .../server/McpServerFeatures.java | 360 +++- .../server/McpSyncServer.java | 48 + .../server/McpSyncServerExchange.java | 78 + .../HttpServletSseServerTransport.java | 5 +- ...HttpServletSseServerTransportProvider.java | 432 +++++ .../transport/StdioServerTransport.java | 3 + .../StdioServerTransportProvider.java | 306 +++ .../spec/ClientMcpTransport.java | 2 + .../spec/DefaultMcpSession.java | 3 + .../spec/McpClientSession.java | 288 +++ .../spec/McpClientTransport.java | 21 + .../spec/McpServerSession.java | 354 ++++ .../spec/McpServerTransport.java | 11 + .../spec/McpServerTransportProvider.java | 66 + .../modelcontextprotocol/spec/McpSession.java | 15 +- .../spec/McpTransport.java | 9 +- .../spec/ServerMcpTransport.java | 2 + .../MockMcpTransport.java | 6 +- .../client/AbstractMcpAsyncClientTests.java | 12 +- .../client/AbstractMcpSyncClientTests.java | 12 +- .../client/HttpSseMcpAsyncClientTests.java | 6 +- .../client/HttpSseMcpSyncClientTests.java | 6 +- .../client/StdioMcpAsyncClientTests.java | 4 +- .../client/StdioMcpSyncClientTests.java | 6 +- ...AbstractMcpAsyncServerDeprecatedTests.java | 466 +++++ .../server/AbstractMcpAsyncServerTests.java | 127 +- .../AbstractMcpSyncServerDeprecatedTests.java | 433 +++++ .../server/AbstractMcpSyncServerTests.java | 131 +- .../server/BaseMcpAsyncServerTests.java | 5 + ...rvletSseMcpAsyncServerDeprecatedTests.java | 26 + .../server/ServletSseMcpAsyncServerTests.java | 10 +- ...ervletSseMcpSyncServerDeprecatedTests.java | 26 + .../server/ServletSseMcpSyncServerTests.java | 10 +- .../StdioMcpAsyncServerDeprecatedTests.java | 25 + .../server/StdioMcpAsyncServerTests.java | 7 +- .../StdioMcpSyncServerDeprecatedTests.java | 25 + .../server/StdioMcpSyncServerTests.java | 10 +- .../server/transport/BlockingInputStream.java | 69 - ...rverTransportProviderIntegrationTests.java | 493 +++++ .../StdioServerTransportProviderTests.java | 227 +++ ...nTests.java => McpClientSessionTests.java} | 20 +- migration-0.8.0.md | 328 ++++ 76 files changed, 9963 insertions(+), 1121 deletions(-) create mode 100644 mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java create mode 100644 mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerDeprecatedTests.java create mode 100644 mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerDeprecatecTests.java create mode 100644 mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/legacy/WebFluxSseIntegrationTests.java create mode 100644 mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java create mode 100644 mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportDeprecatedTests.java create mode 100644 mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationDeprecatedTests.java create mode 100644 mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportDeprecatedTests.java create mode 100644 mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerDeprecatedTests.java create mode 100644 mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerDeprecatedTests.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/spec/McpClientTransport.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransport.java create mode 100644 mcp/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProvider.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerDeprecatedTests.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerDeprecatedTests.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/BaseMcpAsyncServerTests.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerDeprecatedTests.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerDeprecatedTests.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerDeprecatedTests.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerDeprecatedTests.java delete mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/transport/BlockingInputStream.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProviderIntegrationTests.java create mode 100644 mcp/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java rename mcp/src/test/java/io/modelcontextprotocol/spec/{DefaultMcpSessionTests.java => McpClientSessionTests.java} (90%) create mode 100644 migration-0.8.0.md diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java index 8ea65fd..b0dfa89 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java @@ -9,7 +9,7 @@ import java.util.function.Function; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.spec.ClientMcpTransport; +import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage; @@ -58,7 +58,7 @@ import org.springframework.web.reactive.function.client.WebClient; * "https://spec.modelcontextprotocol.io/specification/basic/transports/#http-with-sse">MCP * HTTP with SSE Transport Specification */ -public class WebFluxSseClientTransport implements ClientMcpTransport { +public class WebFluxSseClientTransport implements McpClientTransport { private static final Logger logger = LoggerFactory.getLogger(WebFluxSseClientTransport.class); diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransport.java index bed7293..fb0b581 100644 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransport.java +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransport.java @@ -60,7 +60,10 @@ import org.springframework.web.reactive.function.server.ServerResponse; * @author Alexandros Pappas * @see ServerMcpTransport * @see ServerSentEvent + * @deprecated This class will be removed in 0.9.0. Use + * {@link WebFluxSseServerTransportProvider}. */ +@Deprecated public class WebFluxSseServerTransport implements ServerMcpTransport { private static final Logger logger = LoggerFactory.getLogger(WebFluxSseServerTransport.class); @@ -182,16 +185,16 @@ public class WebFluxSseServerTransport implements ServerMcpTransport { try {// @formatter:off String jsonText = objectMapper.writeValueAsString(message); ServerSentEvent event = ServerSentEvent.builder() - .event(MESSAGE_EVENT_TYPE) - .data(jsonText) - .build(); + .event(MESSAGE_EVENT_TYPE) + .data(jsonText) + .build(); logger.debug("Attempting to broadcast message to {} active sessions", sessions.size()); List failedSessions = sessions.values().stream() - .filter(session -> session.messageSink.tryEmitNext(event).isFailure()) - .map(session -> session.id) - .toList(); + .filter(session -> session.messageSink.tryEmitNext(event).isFailure()) + .map(session -> session.id) + .toList(); if (failedSessions.isEmpty()) { logger.debug("Successfully broadcast message to all sessions"); @@ -407,4 +410,4 @@ public class WebFluxSseServerTransport implements ServerMcpTransport { } -} +} \ No newline at end of file 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 new file mode 100644 index 0000000..cf3eeae --- /dev/null +++ b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java @@ -0,0 +1,351 @@ +package io.modelcontextprotocol.server.transport; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.ServerMcpTransport; +import io.modelcontextprotocol.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Exceptions; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; + +/** + * Server-side implementation of the MCP (Model Context Protocol) HTTP transport using + * Server-Sent Events (SSE). This implementation provides a bidirectional communication + * channel between MCP clients and servers using HTTP POST for client-to-server messages + * and SSE for server-to-client messages. + * + *

+ * Key features: + *

    + *
  • Implements the {@link McpServerTransportProvider} interface that allows managing + * {@link McpServerSession} instances and enabling their communication with the + * {@link McpServerTransport} abstraction.
  • + *
  • Uses WebFlux for non-blocking request handling and SSE support
  • + *
  • Maintains client sessions for reliable message delivery
  • + *
  • Supports graceful shutdown with session cleanup
  • + *
  • Thread-safe message broadcasting to multiple clients
  • + *
+ * + *

+ * The transport sets up two main endpoints: + *

    + *
  • SSE endpoint (/sse) - For establishing SSE connections with clients
  • + *
  • Message endpoint (configurable) - For receiving JSON-RPC messages from clients
  • + *
+ * + *

+ * This implementation is thread-safe and can handle multiple concurrent client + * connections. It uses {@link ConcurrentHashMap} for session management and Project + * Reactor's non-blocking APIs for message processing and delivery. + * + * @author Christian Tzolov + * @author Alexandros Pappas + * @author Dariusz Jędrzejczyk + * @see McpServerTransport + * @see ServerSentEvent + */ +public class WebFluxSseServerTransportProvider implements McpServerTransportProvider { + + private static final Logger logger = LoggerFactory.getLogger(WebFluxSseServerTransportProvider.class); + + /** + * Event type for JSON-RPC messages sent through the SSE connection. + */ + public static final String MESSAGE_EVENT_TYPE = "message"; + + /** + * Event type for sending the message endpoint URI to clients. + */ + public static final String ENDPOINT_EVENT_TYPE = "endpoint"; + + /** + * Default SSE endpoint path as specified by the MCP transport specification. + */ + public static final String DEFAULT_SSE_ENDPOINT = "/sse"; + + private final ObjectMapper objectMapper; + + private final String messageEndpoint; + + private final String sseEndpoint; + + private final RouterFunction routerFunction; + + private McpServerSession.Factory sessionFactory; + + /** + * Map of active client sessions, keyed by session ID. + */ + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + /** + * Flag indicating if the transport is shutting down. + */ + private volatile boolean isClosing = false; + + /** + * Constructs a new WebFlux SSE server transport provider instance. + * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization + * of MCP messages. Must not be null. + * @param messageEndpoint The endpoint URI where clients should send their JSON-RPC + * messages. This endpoint will be communicated to clients during SSE connection + * setup. Must not be null. + * @throws IllegalArgumentException if either parameter is null + */ + public WebFluxSseServerTransportProvider(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + Assert.notNull(messageEndpoint, "Message endpoint must not be null"); + Assert.notNull(sseEndpoint, "SSE endpoint must not be null"); + + this.objectMapper = objectMapper; + this.messageEndpoint = messageEndpoint; + this.sseEndpoint = sseEndpoint; + this.routerFunction = RouterFunctions.route() + .GET(this.sseEndpoint, this::handleSseConnection) + .POST(this.messageEndpoint, this::handleMessage) + .build(); + } + + /** + * Constructs a new WebFlux SSE server transport provider instance with the default + * SSE endpoint. + * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization + * of MCP messages. Must not be null. + * @param messageEndpoint The endpoint URI where clients should send their JSON-RPC + * messages. This endpoint will be communicated to clients during SSE connection + * setup. Must not be null. + * @throws IllegalArgumentException if either parameter is null + */ + public WebFluxSseServerTransportProvider(ObjectMapper objectMapper, String messageEndpoint) { + this(objectMapper, messageEndpoint, DEFAULT_SSE_ENDPOINT); + } + + @Override + public void setSessionFactory(McpServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + /** + * Broadcasts a JSON-RPC message to all connected clients through their SSE + * connections. The message is serialized to JSON and sent as a server-sent event to + * each active session. + * + *

+ * The method: + *

    + *
  • Serializes the message to JSON
  • + *
  • Creates a server-sent event with the message data
  • + *
  • Attempts to send the event to all active sessions
  • + *
  • Tracks and reports any delivery failures
  • + *
+ * @param method The JSON-RPC method to send to clients + * @param params The method parameters to send to clients + * @return A Mono that completes when the message has been sent to all sessions, or + * errors if any session fails to receive the message + */ + @Override + public Mono notifyClients(String method, Map params) { + if (sessions.isEmpty()) { + logger.debug("No active sessions to broadcast message to"); + return Mono.empty(); + } + + logger.debug("Attempting to broadcast message to {} active sessions", sessions.size()); + + return Flux.fromStream(sessions.values().stream()) + .flatMap(session -> session.sendNotification(method, params) + .doOnError(e -> logger.error("Failed to " + "send message to session " + "{}: {}", session.getId(), + e.getMessage())) + .onErrorComplete()) + .then(); + } + + // 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. + * + *

+ * The shutdown process: + *

    + *
  • Marks the transport as closing to prevent new connections
  • + *
  • Closes each active session
  • + *
  • Removes closed sessions from the sessions map
  • + *
  • Times out after 5 seconds if shutdown takes too long
  • + *
+ * @return A Mono that completes when all sessions have been closed + */ + @Override + public Mono closeGracefully() { + return Flux.fromIterable(sessions.values()) + .doFirst(() -> logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size())) + .flatMap(McpServerSession::closeGracefully) + .then(); + } + + /** + * Returns the WebFlux router function that defines the transport's HTTP endpoints. + * This router function should be integrated into the application's web configuration. + * + *

+ * The router function defines two endpoints: + *

    + *
  • GET {sseEndpoint} - For establishing SSE connections
  • + *
  • POST {messageEndpoint} - For receiving client messages
  • + *
+ * @return The configured {@link RouterFunction} for handling HTTP requests + */ + public RouterFunction getRouterFunction() { + return this.routerFunction; + } + + /** + * Handles new SSE connection requests from clients. Creates a new session for each + * connection and sets up the SSE event stream. + * @param request The incoming server request + * @return A Mono which emits a response with the SSE event stream + */ + private Mono handleSseConnection(ServerRequest request) { + if (isClosing) { + return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); + } + + return ServerResponse.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(Flux.>create(sink -> { + WebFluxMcpSessionTransport sessionTransport = new WebFluxMcpSessionTransport(sink); + + McpServerSession session = sessionFactory.create(sessionTransport); + String sessionId = session.getId(); + + logger.debug("Created new SSE connection for session: {}", sessionId); + sessions.put(sessionId, session); + + // Send initial endpoint event + logger.debug("Sending initial endpoint event to session: {}", sessionId); + sink.next(ServerSentEvent.builder() + .event(ENDPOINT_EVENT_TYPE) + .data(messageEndpoint + "?sessionId=" + sessionId) + .build()); + sink.onCancel(() -> { + logger.debug("Session {} cancelled", sessionId); + sessions.remove(sessionId); + }); + }), ServerSentEvent.class); + } + + /** + * Handles incoming JSON-RPC messages from clients. Deserializes the message and + * processes it through the configured message handler. + * + *

+ * The handler: + *

    + *
  • Deserializes the incoming JSON-RPC message
  • + *
  • Passes it through the message handler chain
  • + *
  • Returns appropriate HTTP responses based on processing results
  • + *
  • Handles various error conditions with appropriate error responses
  • + *
+ * @param request The incoming server request containing the JSON-RPC message + * @return A Mono emitting the response indicating the message processing result + */ + private Mono handleMessage(ServerRequest request) { + if (isClosing) { + return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); + } + + if (request.queryParam("sessionId").isEmpty()) { + return ServerResponse.badRequest().bodyValue(new McpError("Session ID missing in message endpoint")); + } + + McpServerSession session = sessions.get(request.queryParam("sessionId").get()); + + return request.bodyToMono(String.class).flatMap(body -> { + try { + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body); + return session.handle(message).flatMap(response -> ServerResponse.ok().build()).onErrorResume(error -> { + logger.error("Error processing message: {}", error.getMessage()); + // TODO: instead of signalling the error, just respond with 200 OK + // - the error is signalled on the SSE connection + // return ServerResponse.ok().build(); + return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR) + .bodyValue(new McpError(error.getMessage())); + }); + } + catch (IllegalArgumentException | IOException e) { + logger.error("Failed to deserialize message: {}", e.getMessage()); + return ServerResponse.badRequest().bodyValue(new McpError("Invalid message format")); + } + }); + } + + private class WebFluxMcpSessionTransport implements McpServerTransport { + + private final FluxSink> sink; + + public WebFluxMcpSessionTransport(FluxSink> sink) { + this.sink = sink; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.fromSupplier(() -> { + try { + return objectMapper.writeValueAsString(message); + } + catch (IOException e) { + throw Exceptions.propagate(e); + } + }).doOnNext(jsonText -> { + ServerSentEvent event = ServerSentEvent.builder() + .event(MESSAGE_EVENT_TYPE) + .data(jsonText) + .build(); + sink.next(event); + }).doOnError(e -> { + // TODO log with sessionid + Throwable exception = Exceptions.unwrap(e); + sink.error(exception); + }).then(); + } + + @Override + public T unmarshalFrom(Object data, TypeReference typeRef) { + return objectMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(sink::complete); + } + + @Override + public void close() { + sink.complete(); + } + + } + +} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java index 4cd24c6..57bcd19 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java @@ -16,7 +16,7 @@ import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; @@ -30,9 +30,9 @@ import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import io.modelcontextprotocol.spec.McpSchema.Tool; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; import reactor.netty.DisposableServer; import reactor.netty.http.server.HttpServer; import reactor.test.StepVerifier; @@ -44,8 +44,8 @@ import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.server.RouterFunctions; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.mock; public class WebFluxSseIntegrationTests { @@ -55,16 +55,16 @@ public class WebFluxSseIntegrationTests { private DisposableServer httpServer; - private WebFluxSseServerTransport mcpServerTransport; + private WebFluxSseServerTransportProvider mcpServerTransportProvider; ConcurrentHashMap clientBulders = new ConcurrentHashMap<>(); @BeforeEach public void before() { - this.mcpServerTransport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + this.mcpServerTransportProvider = new WebFluxSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT); - HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpServerTransport.getRouterFunction()); + HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpServerTransportProvider.getRouterFunction()); ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); @@ -84,57 +84,43 @@ public class WebFluxSseIntegrationTests { // --------------------------------------- // Sampling Tests // --------------------------------------- - @Test - void testCreateMessageWithoutInitialization() { - var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); - - var messages = List - .of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))); - var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); - - var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null, - McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of()); - - StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class) - .hasMessage("Client must be initialized. Call the initialize method first!"); - }); - } - @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "httpclient", "webflux" }) void testCreateMessageWithoutSamplingCapabilities(String clientType) { - var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); - var clientBuilder = clientBulders.get(clientType); - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build(); + McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> { - InitializeResult initResult = client.initialize(); - assertThat(initResult).isNotNull(); + exchange.createMessage(mock(McpSchema.CreateMessageRequest.class)).block(); - var messages = List - .of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))); - var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); + return Mono.just(mock(CallToolResult.class)); + }); - var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null, - McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of()); + McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build(); - StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class) + // Create client without sampling capabilities + var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build(); + + assertThat(client.initialize()).isNotNull(); + + try { + client.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + catch (McpError e) { + assertThat(e).isInstanceOf(McpError.class) .hasMessage("Client must be configured with sampling capabilities"); - }); + } } @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "httpclient", "webflux" }) void testCreateMessageSuccess(String clientType) throws InterruptedException { + // Client var clientBuilder = clientBulders.get(clientType); - var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); - Function samplingHandler = request -> { assertThat(request.messages()).hasSize(1); assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); @@ -143,29 +129,54 @@ public class WebFluxSseIntegrationTests { CreateMessageResult.StopReason.STOP_SEQUENCE); }; - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) .build(); - InitializeResult initResult = client.initialize(); + // Server + + CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), + null); + + McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> { + + var messages = List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Test message"))); + var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); + + var craeteMessageRequest = new McpSchema.CreateMessageRequest(messages, modelPrefs, null, + McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), + Map.of()); + + StepVerifier.create(exchange.createMessage(craeteMessageRequest)).consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.role()).isEqualTo(Role.USER); + assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class); + assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message"); + assertThat(result.model()).isEqualTo("MockModelName"); + assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE); + }).verifyComplete(); + + return Mono.just(callResponse); + }); + + var mcpServer = McpServer.async(mcpServerTransportProvider) + .serverInfo("test-server", "1.0.0") + .tools(tool) + .build(); + + InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - var messages = List - .of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))); - var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null, - McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of()); + assertThat(response).isNotNull(); + assertThat(response).isEqualTo(callResponse); - StepVerifier.create(mcpAsyncServer.createMessage(request)).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.role()).isEqualTo(Role.USER); - assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message"); - assertThat(result.model()).isEqualTo("MockModelName"); - assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE); - }).verifyComplete(); + mcpClient.close(); + mcpServer.close(); } // --------------------------------------- @@ -179,8 +190,8 @@ public class WebFluxSseIntegrationTests { List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.sync(mcpServerTransport) - .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) @@ -192,8 +203,6 @@ public class WebFluxSseIntegrationTests { assertThat(rootsRef.get()).isNull(); - assertThat(mcpServer.listRoots().roots()).containsAll(roots); - mcpClient.rootsListChangedNotification(); await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { @@ -222,23 +231,33 @@ public class WebFluxSseIntegrationTests { @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "httpclient", "webflux" }) void testRootsWithoutCapability(String clientType) { + var clientBuilder = clientBulders.get(clientType); - var mcpServer = McpServer.sync(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> { - }).build(); + McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> { + + exchange.listRoots(); // try to list roots + + return mock(CallToolResult.class); + }); + + var mcpServer = McpServer.sync(mcpServerTransportProvider).rootsChangeHandler((exchange, rootsUpdate) -> { + }).tools(tool).build(); // Create client without roots capability - var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()) // No - // roots - // capability - .build(); + // No roots capability + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build(); - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); + assertThat(mcpClient.initialize()).isNotNull(); // Attempt to list roots should fail - assertThatThrownBy(() -> mcpServer.listRoots().roots()).isInstanceOf(McpError.class) - .hasMessage("Roots not supported"); + try { + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + catch (McpError e) { + assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported"); + } mcpClient.close(); mcpServer.close(); @@ -246,12 +265,12 @@ public class WebFluxSseIntegrationTests { @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "httpclient", "webflux" }) - void testRootsWithEmptyRootsList(String clientType) { + void testRootsNotifciationWithEmptyRootsList(String clientType) { var clientBuilder = clientBulders.get(clientType); AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.sync(mcpServerTransport) - .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) @@ -273,7 +292,7 @@ public class WebFluxSseIntegrationTests { @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "httpclient", "webflux" }) - void testRootsWithMultipleConsumers(String clientType) { + void testRootsWithMultipleHandlers(String clientType) { var clientBuilder = clientBulders.get(clientType); List roots = List.of(new Root("uri1://", "root1")); @@ -281,9 +300,9 @@ public class WebFluxSseIntegrationTests { AtomicReference> rootsRef1 = new AtomicReference<>(); AtomicReference> rootsRef2 = new AtomicReference<>(); - var mcpServer = McpServer.sync(mcpServerTransport) - .rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate)) - .rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate)) + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef1.set(rootsUpdate)) + .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate)) .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) @@ -313,8 +332,8 @@ public class WebFluxSseIntegrationTests { List roots = List.of(new Root("uri1://", "root1")); AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.sync(mcpServerTransport) - .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) @@ -356,8 +375,8 @@ public class WebFluxSseIntegrationTests { var clientBuilder = clientBulders.get(clientType); var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); - McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( - new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> { + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> { // perform a blocking call to a remote service String response = RestClient.create() .get() @@ -368,7 +387,7 @@ public class WebFluxSseIntegrationTests { return callResponse; }); - var mcpServer = McpServer.sync(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) .build(); @@ -396,8 +415,8 @@ public class WebFluxSseIntegrationTests { var clientBuilder = clientBulders.get(clientType); var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); - McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( - new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> { + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> { // perform a blocking call to a remote service String response = RestClient.create() .get() @@ -408,7 +427,7 @@ public class WebFluxSseIntegrationTests { return callResponse; }); - var mcpServer = McpServer.sync(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) .build(); @@ -446,8 +465,8 @@ public class WebFluxSseIntegrationTests { }); // Add a new tool - McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration( - new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse); + McpServerFeatures.SyncToolSpecification tool2 = new McpServerFeatures.SyncToolSpecification( + new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), (exchange, request) -> callResponse); mcpServer.addTool(tool2); @@ -459,4 +478,21 @@ public class WebFluxSseIntegrationTests { mcpServer.close(); } + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testInitialize(String clientType) { + + var clientBuilder = clientBulders.get(clientType); + + var mcpServer = McpServer.sync(mcpServerTransportProvider).build(); + + var mcpClient = clientBuilder.build(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + mcpClient.close(); + mcpServer.close(); + } + } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java index 0dccb27..2dd587d 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java @@ -7,7 +7,7 @@ package io.modelcontextprotocol.client; import java.time.Duration; import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; -import io.modelcontextprotocol.spec.ClientMcpTransport; +import io.modelcontextprotocol.spec.McpClientTransport; import org.junit.jupiter.api.Timeout; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; @@ -32,7 +32,7 @@ class WebFluxSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { .waitingFor(Wait.forHttp("/").forStatusCode(404)); @Override - protected ClientMcpTransport createMcpTransport() { + protected McpClientTransport createMcpTransport() { return new WebFluxSseClientTransport(WebClient.builder().baseUrl(host)); } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java index f5cab7b..72b390d 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java @@ -7,7 +7,7 @@ package io.modelcontextprotocol.client; import java.time.Duration; import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; -import io.modelcontextprotocol.spec.ClientMcpTransport; +import io.modelcontextprotocol.spec.McpClientTransport; import org.junit.jupiter.api.Timeout; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; @@ -32,7 +32,7 @@ class WebFluxSseMcpSyncClientTests extends AbstractMcpSyncClientTests { .waitingFor(Wait.forHttp("/").forStatusCode(404)); @Override - protected ClientMcpTransport createMcpTransport() { + protected McpClientTransport createMcpTransport() { return new WebFluxSseClientTransport(WebClient.builder().baseUrl(host)); } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerDeprecatedTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerDeprecatedTests.java new file mode 100644 index 0000000..b460284 --- /dev/null +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerDeprecatedTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; +import io.modelcontextprotocol.spec.ServerMcpTransport; +import org.junit.jupiter.api.Timeout; +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.server.RouterFunctions; + +/** + * Tests for {@link McpAsyncServer} using {@link WebFluxSseServerTransport}. + * + * @author Christian Tzolov + */ +@Deprecated +@Timeout(15) // Giving extra time beyond the client timeout +class WebFluxSseMcpAsyncServerDeprecatedTests extends AbstractMcpAsyncServerDeprecatedTests { + + private static final int PORT = 8181; + + private static final String MESSAGE_ENDPOINT = "/mcp/message"; + + private DisposableServer httpServer; + + @Override + protected ServerMcpTransport createMcpTransport() { + var transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + + HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction()); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); + httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); + + return transport; + } + + @Override + protected void onStart() { + } + + @Override + protected void onClose() { + if (httpServer != null) { + httpServer.disposeNow(); + } + } + +} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java index 1ed0d99..5fa787a 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java @@ -5,8 +5,8 @@ package io.modelcontextprotocol.server; import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; -import io.modelcontextprotocol.spec.ServerMcpTransport; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerTransportProvider; import org.junit.jupiter.api.Timeout; import reactor.netty.DisposableServer; import reactor.netty.http.server.HttpServer; @@ -16,7 +16,7 @@ import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import org.springframework.web.reactive.function.server.RouterFunctions; /** - * Tests for {@link McpAsyncServer} using {@link WebFluxSseServerTransport}. + * Tests for {@link McpAsyncServer} using {@link WebFluxSseServerTransportProvider}. * * @author Christian Tzolov */ @@ -30,14 +30,13 @@ class WebFluxSseMcpAsyncServerTests extends AbstractMcpAsyncServerTests { private DisposableServer httpServer; @Override - protected ServerMcpTransport createMcpTransport() { - var transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + protected McpServerTransportProvider createMcpTransportProvider() { + var transportProvider = new WebFluxSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT); - HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction()); + HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction()); ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); - - return transport; + return transportProvider; } @Override diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerDeprecatecTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerDeprecatecTests.java new file mode 100644 index 0000000..be2bf6c --- /dev/null +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerDeprecatecTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; +import io.modelcontextprotocol.spec.ServerMcpTransport; +import org.junit.jupiter.api.Timeout; +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.server.RouterFunctions; + +/** + * Tests for {@link McpSyncServer} using {@link WebFluxSseServerTransport}. + * + * @author Christian Tzolov + */ +@Deprecated +@Timeout(15) // Giving extra time beyond the client timeout +class WebFluxSseMcpSyncServerDeprecatecTests extends AbstractMcpSyncServerDeprecatedTests { + + private static final int PORT = 8182; + + private static final String MESSAGE_ENDPOINT = "/mcp/message"; + + private DisposableServer httpServer; + + private WebFluxSseServerTransport transport; + + @Override + protected ServerMcpTransport createMcpTransport() { + transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + return transport; + } + + @Override + protected void onStart() { + HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction()); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); + httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); + } + + @Override + protected void onClose() { + if (httpServer != null) { + httpServer.disposeNow(); + } + } + +} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java index 4db00dd..d3672e3 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java @@ -5,8 +5,8 @@ package io.modelcontextprotocol.server; import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; -import io.modelcontextprotocol.spec.ServerMcpTransport; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerTransportProvider; import org.junit.jupiter.api.Timeout; import reactor.netty.DisposableServer; import reactor.netty.http.server.HttpServer; @@ -16,7 +16,7 @@ import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import org.springframework.web.reactive.function.server.RouterFunctions; /** - * Tests for {@link McpSyncServer} using {@link WebFluxSseServerTransport}. + * Tests for {@link McpSyncServer} using {@link WebFluxSseServerTransportProvider}. * * @author Christian Tzolov */ @@ -29,17 +29,17 @@ class WebFluxSseMcpSyncServerTests extends AbstractMcpSyncServerTests { private DisposableServer httpServer; - private WebFluxSseServerTransport transport; + private WebFluxSseServerTransportProvider transportProvider; @Override - protected ServerMcpTransport createMcpTransport() { - transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); - return transport; + protected McpServerTransportProvider createMcpTransportProvider() { + transportProvider = new WebFluxSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT); + return transportProvider; } @Override protected void onStart() { - HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction()); + HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction()); ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); } diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/legacy/WebFluxSseIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/legacy/WebFluxSseIntegrationTests.java new file mode 100644 index 0000000..981e114 --- /dev/null +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/legacy/WebFluxSseIntegrationTests.java @@ -0,0 +1,459 @@ +/* + * Copyright 2024 - 2024 the original author or authors. + */ +package io.modelcontextprotocol.server.legacy; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; +import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; +import io.modelcontextprotocol.spec.McpSchema.InitializeResult; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.Root; +import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; +import reactor.test.StepVerifier; + +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.server.RouterFunctions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +public class WebFluxSseIntegrationTests { + + private static final int PORT = 8182; + + private static final String MESSAGE_ENDPOINT = "/mcp/message"; + + private DisposableServer httpServer; + + private WebFluxSseServerTransport mcpServerTransport; + + ConcurrentHashMap clientBulders = new ConcurrentHashMap<>(); + + @BeforeEach + public void before() { + + this.mcpServerTransport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + + HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpServerTransport.getRouterFunction()); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); + this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); + + clientBulders.put("httpclient", McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT))); + clientBulders.put("webflux", + McpClient.sync(new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:" + PORT)))); + + } + + @AfterEach + public void after() { + if (httpServer != null) { + httpServer.disposeNow(); + } + } + + // --------------------------------------- + // Sampling Tests + // --------------------------------------- + @Test + void testCreateMessageWithoutInitialization() { + var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); + + var messages = List.of(new McpSchema.SamplingMessage(Role.USER, new McpSchema.TextContent("Test message"))); + var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); + + var request = new CreateMessageRequest(messages, modelPrefs, null, + CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of()); + + StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized. Call the initialize method first!"); + }); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testCreateMessageWithoutSamplingCapabilities(String clientType) { + + var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); + + var clientBuilder = clientBulders.get(clientType); + + var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build(); + + InitializeResult initResult = client.initialize(); + assertThat(initResult).isNotNull(); + + var messages = List.of(new McpSchema.SamplingMessage(Role.USER, new McpSchema.TextContent("Test message"))); + var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); + + var request = new CreateMessageRequest(messages, modelPrefs, null, + CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of()); + + StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Client must be configured with sampling capabilities"); + }); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testCreateMessageSuccess(String clientType) throws InterruptedException { + + var clientBuilder = clientBulders.get(clientType); + + var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); + + Function samplingHandler = request -> { + assertThat(request.messages()).hasSize(1); + assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); + + return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", + CreateMessageResult.StopReason.STOP_SEQUENCE); + }; + + var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + .capabilities(ClientCapabilities.builder().sampling().build()) + .sampling(samplingHandler) + .build(); + + InitializeResult initResult = client.initialize(); + assertThat(initResult).isNotNull(); + + var messages = List.of(new McpSchema.SamplingMessage(Role.USER, new McpSchema.TextContent("Test message"))); + var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); + + var request = new CreateMessageRequest(messages, modelPrefs, null, + CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of()); + + StepVerifier.create(mcpAsyncServer.createMessage(request)).consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.role()).isEqualTo(Role.USER); + assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class); + assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message"); + assertThat(result.model()).isEqualTo("MockModelName"); + assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE); + }).verifyComplete(); + } + + // --------------------------------------- + // Roots Tests + // --------------------------------------- + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testRootsSuccess(String clientType) { + var clientBuilder = clientBulders.get(clientType); + + List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); + + AtomicReference> rootsRef = new AtomicReference<>(); + var mcpServer = McpServer.sync(mcpServerTransport) + .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + .build(); + + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + .roots(roots) + .build(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(rootsRef.get()).isNull(); + + assertThat(mcpServer.listRoots().roots()).containsAll(roots); + + mcpClient.rootsListChangedNotification(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(roots); + }); + + // Remove a root + mcpClient.removeRoot(roots.get(0).uri()); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(roots.get(1))); + }); + + // Add a new root + var root3 = new Root("uri3://", "root3"); + mcpClient.addRoot(root3); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3)); + }); + + mcpClient.close(); + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testRootsWithoutCapability(String clientType) { + var clientBuilder = clientBulders.get(clientType); + + var mcpServer = McpServer.sync(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> { + }).build(); + + // Create client without roots capability + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()) // No + // roots + // capability + .build(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + // Attempt to list roots should fail + assertThatThrownBy(() -> mcpServer.listRoots().roots()).isInstanceOf(McpError.class) + .hasMessage("Roots not supported"); + + mcpClient.close(); + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testRootsWithEmptyRootsList(String clientType) { + var clientBuilder = clientBulders.get(clientType); + + AtomicReference> rootsRef = new AtomicReference<>(); + var mcpServer = McpServer.sync(mcpServerTransport) + .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + .build(); + + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + .roots(List.of()) // Empty roots list + .build(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + mcpClient.rootsListChangedNotification(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).isEmpty(); + }); + + mcpClient.close(); + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testRootsWithMultipleConsumers(String clientType) { + var clientBuilder = clientBulders.get(clientType); + + List roots = List.of(new Root("uri1://", "root1")); + + AtomicReference> rootsRef1 = new AtomicReference<>(); + AtomicReference> rootsRef2 = new AtomicReference<>(); + + var mcpServer = McpServer.sync(mcpServerTransport) + .rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate)) + .rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate)) + .build(); + + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + .roots(roots) + .build(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + mcpClient.rootsListChangedNotification(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef1.get()).containsAll(roots); + assertThat(rootsRef2.get()).containsAll(roots); + }); + + mcpClient.close(); + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testRootsServerCloseWithActiveSubscription(String clientType) { + + var clientBuilder = clientBulders.get(clientType); + + List roots = List.of(new Root("uri1://", "root1")); + + AtomicReference> rootsRef = new AtomicReference<>(); + var mcpServer = McpServer.sync(mcpServerTransport) + .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + .build(); + + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + .roots(roots) + .build(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + mcpClient.rootsListChangedNotification(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(roots); + }); + + // Close server while subscription is active + mcpServer.close(); + + // Verify client can handle server closure gracefully + mcpClient.close(); + } + + // --------------------------------------- + // Tools Tests + // --------------------------------------- + + String emptyJsonSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """; + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testToolCallSuccess(String clientType) { + + var clientBuilder = clientBulders.get(clientType); + + var callResponse = new CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); + McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( + new Tool("tool1", "tool1 description", emptyJsonSchema), request -> { + // perform a blocking call to a remote service + String response = RestClient.create() + .get() + .uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md") + .retrieve() + .body(String.class); + assertThat(response).isNotBlank(); + return callResponse; + }); + + var mcpServer = McpServer.sync(mcpServerTransport) + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(tool1) + .build(); + + var mcpClient = clientBuilder.build(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); + + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + + assertThat(response).isNotNull(); + assertThat(response).isEqualTo(callResponse); + + mcpClient.close(); + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testToolListChangeHandlingSuccess(String clientType) { + + var clientBuilder = clientBulders.get(clientType); + + var callResponse = new CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); + McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( + new Tool("tool1", "tool1 description", emptyJsonSchema), request -> { + // perform a blocking call to a remote service + String response = RestClient.create() + .get() + .uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md") + .retrieve() + .body(String.class); + assertThat(response).isNotBlank(); + return callResponse; + }); + + var mcpServer = McpServer.sync(mcpServerTransport) + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(tool1) + .build(); + + AtomicReference> rootsRef = new AtomicReference<>(); + var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { + // perform a blocking call to a remote service + String response = RestClient.create() + .get() + .uri("https://github.com/modelcontextprotocol/specification/blob/main/README.md") + .retrieve() + .body(String.class); + assertThat(response).isNotBlank(); + rootsRef.set(toolsUpdate); + }).build(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(rootsRef.get()).isNull(); + + assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); + + mcpServer.notifyToolsListChanged(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(tool1.tool())); + }); + + // Remove a tool + mcpServer.removeTool("tool1"); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).isEmpty(); + }); + + // Add a new tool + McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration( + new Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse); + + mcpServer.addTool(tool2); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(tool2.tool())); + }); + + mcpClient.close(); + mcpServer.close(); + } + +} diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java index 00928ec..23193d1 100644 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java +++ b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransport.java @@ -33,6 +33,9 @@ import org.springframework.web.servlet.function.ServerResponse.SseBuilder; * a bridge between synchronous WebMVC operations and reactive programming patterns to * maintain compatibility with the reactive transport interface. * + * @deprecated This class will be removed in 0.9.0. Use + * {@link WebMvcSseServerTransportProvider}. + * *

* Key features: *