diff --git a/README.md b/README.md index 0d36311..486e2be 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,14 @@ Add the following dependencies to your Maven project: org.springframework.experimental mcp - 0.4.0-SNAPSHOT + 0.5.0-SNAPSHOT org.springframework.experimental spring-ai-mcp - 0.4.0-SNAPSHOT + 0.5.0-SNAPSHOT ``` diff --git a/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc b/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc index a693506..d68726a 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc @@ -25,7 +25,7 @@ Add the following dependency to your Maven project: org.springframework.experimental mcp - 0.4.0-SNAPSHOT + 0.5.0-SNAPSHOT ---- diff --git a/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc b/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc index 04fe69f..35cbecb 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc @@ -40,7 +40,7 @@ Maven:: org.springframework.experimental mcp - 0.4.0-SNAPSHOT + 0.5.0-SNAPSHOT ---- + @@ -50,7 +50,7 @@ Maven:: org.springframework.experimental spring-ai-mcp - 0.4.0-SNAPSHOT + 0.5.0-SNAPSHOT ---- + diff --git a/mcp-docs/src/main/antora/modules/ROOT/pages/spring-mcp.adoc b/mcp-docs/src/main/antora/modules/ROOT/pages/spring-mcp.adoc index fb6ee50..ec762d9 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/spring-mcp.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/spring-mcp.adoc @@ -36,6 +36,6 @@ To use this module, add the following dependency to your Maven project: org.springframework.experimental spring-ai-mcp - 0.4.0-SNAPSHOT + 0.5.0-SNAPSHOT ---- diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java index ee6882d..2a87b3a 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java @@ -374,9 +374,9 @@ private DefaultMcpSession.RequestHandler toolsCallRequestHandler() { return Mono.error(new McpError("Tool not found: " + callToolRequest.name())); } - CallToolResult callResponse = toolRegistration.get().call().apply(callToolRequest.arguments()); - - return Mono.just(callResponse); + return Mono.fromCallable(() -> toolRegistration.get().call().apply(callToolRequest.arguments())) + .map(result -> (Object) result) + .subscribeOn(Schedulers.boundedElastic()); }; } @@ -462,7 +462,9 @@ private DefaultMcpSession.RequestHandler resourcesReadRequestHandler() { }); var resourceUri = resourceRequest.uri(); if (this.resources.containsKey(resourceUri)) { - return Mono.just(this.resources.get(resourceUri).readHandler().apply(resourceRequest)); + return Mono.fromCallable(() -> this.resources.get(resourceUri).readHandler().apply(resourceRequest)) + .map(result -> (Object) result) + .subscribeOn(Schedulers.boundedElastic()); } return Mono.error(new McpError("Resource not found: " + resourceUri)); }; @@ -558,7 +560,10 @@ private DefaultMcpSession.RequestHandler promptsGetRequestHandler() { // Implement prompt retrieval logic here if (this.prompts.containsKey(promptRequest.name())) { - return Mono.just(this.prompts.get(promptRequest.name()).promptHandler().apply(promptRequest)); + return Mono + .fromCallable(() -> this.prompts.get(promptRequest.name()).promptHandler().apply(promptRequest)) + .map(result -> (Object) result) + .subscribeOn(Schedulers.boundedElastic()); } return Mono.error(new McpError("Prompt not found: " + promptRequest.name())); diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java index fb1f8aa..22e32e7 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java @@ -31,17 +31,22 @@ import org.springframework.ai.mcp.client.McpClient; import org.springframework.ai.mcp.client.transport.SseClientTransport; +import org.springframework.ai.mcp.server.McpServer.ToolRegistration; import org.springframework.ai.mcp.server.transport.SseServerTransport; import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities; import org.springframework.ai.mcp.spec.McpSchema.CreateMessageRequest; import org.springframework.ai.mcp.spec.McpSchema.CreateMessageResult; import org.springframework.ai.mcp.spec.McpSchema.InitializeResult; import org.springframework.ai.mcp.spec.McpSchema.Role; import org.springframework.ai.mcp.spec.McpSchema.Root; +import org.springframework.ai.mcp.spec.McpSchema.ServerCapabilities; +import org.springframework.ai.mcp.spec.McpSchema.Tool; 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; @@ -314,4 +319,111 @@ void testRootsServerCloseWithActiveSubscription() { mcpClient.close(); } + // --------------------------------------- + // Tools Tests + // --------------------------------------- + @Test + void testToolCallSuccess() { + + var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); + ToolRegistration tool1 = new ToolRegistration( + new McpSchema.Tool("tool1", "tool1 description", Map.of("city", "String")), request -> { + // perform a blocking call to a remote service + String response = RestClient.create() + .get() + .uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") + .retrieve() + .body(String.class); + assertThat(response).isNotBlank(); + return callResponse; + }); + + var mcpServer = McpServer.using(mcpServerTransport) + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(tool1) + .sync(); + + var mcpClient = clientBuilder.sync(); + + 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(); + } + + @Test + void testToolListChangeHandlingSuccess() { + + var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); + ToolRegistration tool1 = new ToolRegistration( + new McpSchema.Tool("tool1", "tool1 description", Map.of("city", "String")), request -> { + // perform a blocking call to a remote service + String response = RestClient.create() + .get() + .uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") + .retrieve() + .body(String.class); + assertThat(response).isNotBlank(); + return callResponse; + }); + + var mcpServer = McpServer.using(mcpServerTransport) + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(tool1) + .sync(); + + 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/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") + .retrieve() + .body(String.class); + assertThat(response).isNotBlank(); + rootsRef.set(toolsUpdate); + }).sync(); + + 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 + ToolRegistration tool2 = new ToolRegistration( + new McpSchema.Tool("tool2", "tool2 description", Map.of("city", "String")), 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/spring-ai-mcp-sample/README.md b/spring-ai-mcp-sample/README.md index 632e253..9cf76f9 100644 --- a/spring-ai-mcp-sample/README.md +++ b/spring-ai-mcp-sample/README.md @@ -28,7 +28,7 @@ The server can be started in two transport modes, controlled by the `transport.m ### Stdio Mode (Default) ```bash -java -Dtransport.mode=stdio -jar target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar +java -Dtransport.mode=stdio -jar target/spring-ai-mcp-sample-0.5.0-SNAPSHOT.jar ``` The Stdio mode server is automatically started by the client - no explicit server startup is needed. @@ -38,7 +38,7 @@ In Stdio mode the server must not emit any messages/logs to the console (e.g. st ### SSE Mode ```bash -java -Dtransport.mode=sse -jar target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar +java -Dtransport.mode=sse -jar target/spring-ai-mcp-sample-0.5.0-SNAPSHOT.jar ``` ## Sample Clients @@ -49,7 +49,7 @@ The project includes example clients for both transport modes: ```java var stdioParams = ServerParameters.builder("java") .args("-Dtransport.mode=stdio", "-jar", - "target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar") + "target/spring-ai-mcp-sample-0.5.0-SNAPSHOT.jar") .build(); var transport = new StdioClientTransport(stdioParams); diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java index ee84037..d510c72 100644 --- a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java +++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java @@ -28,7 +28,7 @@ public static void main(String[] args) { var stdioParams = ServerParameters.builder("java") .args("-Dtransport.mode=stdio", "-jar", - "spring-ai-mcp-sample/target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar") + "spring-ai-mcp-sample/target/spring-ai-mcp-sample-0.5.0-SNAPSHOT.jar") .build(); var transport = new StdioClientTransport(stdioParams);