Skip to content
This repository was archived by the owner on Feb 14, 2025. It is now read-only.

Commit 580f955

Browse files
committed
feat(McpAsyncServer): Add non-blocking execution for tools and resources
- Execute tool calls, resource reads and prompt handling in a non-blocking manner using Schedulers.boundedElastic(). This prevents blocking operations from impacting server responsiveness. - Added integration tests to verify non-blocking behavior with tools that make HTTP calls to external services. Related to #48 This is a temp patch until #48 is resolved properly.
1 parent b2082a0 commit 580f955

File tree

8 files changed

+132
-15
lines changed

8 files changed

+132
-15
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ Add the following dependencies to your Maven project:
3232
<dependency>
3333
<groupId>org.springframework.experimental</groupId>
3434
<artifactId>mcp</artifactId>
35-
<version>0.4.0-SNAPSHOT</version>
35+
<version>0.5.0-SNAPSHOT</version>
3636
</dependency>
3737

3838
<!-- For Spring AI integration -->
3939
<dependency>
4040
<groupId>org.springframework.experimental</groupId>
4141
<artifactId>spring-ai-mcp</artifactId>
42-
<version>0.4.0-SNAPSHOT</version>
42+
<version>0.5.0-SNAPSHOT</version>
4343
</dependency>
4444
```
4545

mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Add the following dependency to your Maven project:
2525
<dependency>
2626
<groupId>org.springframework.experimental</groupId>
2727
<artifactId>mcp</artifactId>
28-
<version>0.4.0-SNAPSHOT</version>
28+
<version>0.5.0-SNAPSHOT</version>
2929
</dependency>
3030
----
3131

mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Maven::
4040
<dependency>
4141
<groupId>org.springframework.experimental</groupId>
4242
<artifactId>mcp</artifactId>
43-
<version>0.4.0-SNAPSHOT</version>
43+
<version>0.5.0-SNAPSHOT</version>
4444
</dependency>
4545
----
4646
+
@@ -50,7 +50,7 @@ Maven::
5050
<dependency>
5151
<groupId>org.springframework.experimental</groupId>
5252
<artifactId>spring-ai-mcp</artifactId>
53-
<version>0.4.0-SNAPSHOT</version>
53+
<version>0.5.0-SNAPSHOT</version>
5454
</dependency>
5555
----
5656
+

mcp-docs/src/main/antora/modules/ROOT/pages/spring-mcp.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ To use this module, add the following dependency to your Maven project:
3636
<dependency>
3737
<groupId>org.springframework.experimental</groupId>
3838
<artifactId>spring-ai-mcp</artifactId>
39-
<version>0.4.0-SNAPSHOT</version>
39+
<version>0.5.0-SNAPSHOT</version>
4040
</dependency>
4141
----

mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -374,9 +374,9 @@ private DefaultMcpSession.RequestHandler toolsCallRequestHandler() {
374374
return Mono.<Object>error(new McpError("Tool not found: " + callToolRequest.name()));
375375
}
376376

377-
CallToolResult callResponse = toolRegistration.get().call().apply(callToolRequest.arguments());
378-
379-
return Mono.just(callResponse);
377+
return Mono.fromCallable(() -> toolRegistration.get().call().apply(callToolRequest.arguments()))
378+
.map(result -> (Object) result)
379+
.subscribeOn(Schedulers.boundedElastic());
380380
};
381381
}
382382

@@ -462,7 +462,9 @@ private DefaultMcpSession.RequestHandler resourcesReadRequestHandler() {
462462
});
463463
var resourceUri = resourceRequest.uri();
464464
if (this.resources.containsKey(resourceUri)) {
465-
return Mono.just(this.resources.get(resourceUri).readHandler().apply(resourceRequest));
465+
return Mono.fromCallable(() -> this.resources.get(resourceUri).readHandler().apply(resourceRequest))
466+
.map(result -> (Object) result)
467+
.subscribeOn(Schedulers.boundedElastic());
466468
}
467469
return Mono.error(new McpError("Resource not found: " + resourceUri));
468470
};
@@ -558,7 +560,10 @@ private DefaultMcpSession.RequestHandler promptsGetRequestHandler() {
558560

559561
// Implement prompt retrieval logic here
560562
if (this.prompts.containsKey(promptRequest.name())) {
561-
return Mono.just(this.prompts.get(promptRequest.name()).promptHandler().apply(promptRequest));
563+
return Mono
564+
.fromCallable(() -> this.prompts.get(promptRequest.name()).promptHandler().apply(promptRequest))
565+
.map(result -> (Object) result)
566+
.subscribeOn(Schedulers.boundedElastic());
562567
}
563568

564569
return Mono.error(new McpError("Prompt not found: " + promptRequest.name()));

mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,22 @@
3131

3232
import org.springframework.ai.mcp.client.McpClient;
3333
import org.springframework.ai.mcp.client.transport.SseClientTransport;
34+
import org.springframework.ai.mcp.server.McpServer.ToolRegistration;
3435
import org.springframework.ai.mcp.server.transport.SseServerTransport;
3536
import org.springframework.ai.mcp.spec.McpError;
3637
import org.springframework.ai.mcp.spec.McpSchema;
38+
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
3739
import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
3840
import org.springframework.ai.mcp.spec.McpSchema.CreateMessageRequest;
3941
import org.springframework.ai.mcp.spec.McpSchema.CreateMessageResult;
4042
import org.springframework.ai.mcp.spec.McpSchema.InitializeResult;
4143
import org.springframework.ai.mcp.spec.McpSchema.Role;
4244
import org.springframework.ai.mcp.spec.McpSchema.Root;
45+
import org.springframework.ai.mcp.spec.McpSchema.ServerCapabilities;
46+
import org.springframework.ai.mcp.spec.McpSchema.Tool;
4347
import org.springframework.http.server.reactive.HttpHandler;
4448
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
49+
import org.springframework.web.client.RestClient;
4550
import org.springframework.web.reactive.function.client.WebClient;
4651
import org.springframework.web.reactive.function.server.RouterFunctions;
4752

@@ -314,4 +319,111 @@ void testRootsServerCloseWithActiveSubscription() {
314319
mcpClient.close();
315320
}
316321

322+
// ---------------------------------------
323+
// Tools Tests
324+
// ---------------------------------------
325+
@Test
326+
void testToolCallSuccess() {
327+
328+
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
329+
ToolRegistration tool1 = new ToolRegistration(
330+
new McpSchema.Tool("tool1", "tool1 description", Map.of("city", "String")), request -> {
331+
// perform a blocking call to a remote service
332+
String response = RestClient.create()
333+
.get()
334+
.uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md")
335+
.retrieve()
336+
.body(String.class);
337+
assertThat(response).isNotBlank();
338+
return callResponse;
339+
});
340+
341+
var mcpServer = McpServer.using(mcpServerTransport)
342+
.capabilities(ServerCapabilities.builder().tools(true).build())
343+
.tools(tool1)
344+
.sync();
345+
346+
var mcpClient = clientBuilder.sync();
347+
348+
InitializeResult initResult = mcpClient.initialize();
349+
assertThat(initResult).isNotNull();
350+
351+
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
352+
353+
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
354+
355+
assertThat(response).isNotNull();
356+
assertThat(response).isEqualTo(callResponse);
357+
358+
mcpClient.close();
359+
mcpServer.close();
360+
}
361+
362+
@Test
363+
void testToolListChangeHandlingSuccess() {
364+
365+
var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
366+
ToolRegistration tool1 = new ToolRegistration(
367+
new McpSchema.Tool("tool1", "tool1 description", Map.of("city", "String")), request -> {
368+
// perform a blocking call to a remote service
369+
String response = RestClient.create()
370+
.get()
371+
.uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md")
372+
.retrieve()
373+
.body(String.class);
374+
assertThat(response).isNotBlank();
375+
return callResponse;
376+
});
377+
378+
var mcpServer = McpServer.using(mcpServerTransport)
379+
.capabilities(ServerCapabilities.builder().tools(true).build())
380+
.tools(tool1)
381+
.sync();
382+
383+
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
384+
var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
385+
// perform a blocking call to a remote service
386+
String response = RestClient.create()
387+
.get()
388+
.uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md")
389+
.retrieve()
390+
.body(String.class);
391+
assertThat(response).isNotBlank();
392+
rootsRef.set(toolsUpdate);
393+
}).sync();
394+
395+
InitializeResult initResult = mcpClient.initialize();
396+
assertThat(initResult).isNotNull();
397+
398+
assertThat(rootsRef.get()).isNull();
399+
400+
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
401+
402+
mcpServer.notifyToolsListChanged();
403+
404+
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
405+
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
406+
});
407+
408+
// Remove a tool
409+
mcpServer.removeTool("tool1");
410+
411+
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
412+
assertThat(rootsRef.get()).isEmpty();
413+
});
414+
415+
// Add a new tool
416+
ToolRegistration tool2 = new ToolRegistration(
417+
new McpSchema.Tool("tool2", "tool2 description", Map.of("city", "String")), request -> callResponse);
418+
419+
mcpServer.addTool(tool2);
420+
421+
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
422+
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
423+
});
424+
425+
mcpClient.close();
426+
mcpServer.close();
427+
}
428+
317429
}

spring-ai-mcp-sample/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The server can be started in two transport modes, controlled by the `transport.m
2828
### Stdio Mode (Default)
2929

3030
```bash
31-
java -Dtransport.mode=stdio -jar target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar
31+
java -Dtransport.mode=stdio -jar target/spring-ai-mcp-sample-0.5.0-SNAPSHOT.jar
3232
```
3333

3434
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
3838

3939
### SSE Mode
4040
```bash
41-
java -Dtransport.mode=sse -jar target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar
41+
java -Dtransport.mode=sse -jar target/spring-ai-mcp-sample-0.5.0-SNAPSHOT.jar
4242
```
4343

4444
## Sample Clients
@@ -49,7 +49,7 @@ The project includes example clients for both transport modes:
4949
```java
5050
var stdioParams = ServerParameters.builder("java")
5151
.args("-Dtransport.mode=stdio", "-jar",
52-
"target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar")
52+
"target/spring-ai-mcp-sample-0.5.0-SNAPSHOT.jar")
5353
.build();
5454

5555
var transport = new StdioClientTransport(stdioParams);

spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public static void main(String[] args) {
2828

2929
var stdioParams = ServerParameters.builder("java")
3030
.args("-Dtransport.mode=stdio", "-jar",
31-
"spring-ai-mcp-sample/target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar")
31+
"spring-ai-mcp-sample/target/spring-ai-mcp-sample-0.5.0-SNAPSHOT.jar")
3232
.build();
3333

3434
var transport = new StdioClientTransport(stdioParams);

0 commit comments

Comments
 (0)