From 6839d3b74005520ea44942927a0634999474567f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Mon, 20 Jan 2025 18:33:06 +0100 Subject: [PATCH] Refactor McpClient for async and sync interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce new builder patterns and enhance type safety - Add new builder patterns for McpClient and McpServer with sync/async variants - Introduce dedicated McpClientFeatures and McpServerFeatures classes - Enhance type safety and reactive support with proper Mono integration - Add migration guide for 0.6.0 release - Mark old builder methods as deprecated - Update documentation to reflect new APIs BREAKING CHANGE: The old builder patterns using McpClient.using() and McpServer.using() are now deprecated in favor of new sync() and async() builders that provide better type safety and clearer sync/async separation. Follow the migration guide. Resolves #48 Signed-off-by: Dariusz Jędrzejczyk Co-authored-by: Christian Tzolov --- mcp-docs/0.6.0-MIGRATION-GUIDE.md | 258 ++++ .../main/antora/modules/ROOT/pages/mcp.adoc | 90 +- .../BlaAbstractMcpAsyncClientTests.java | 46 +- .../client/BlaAbstractMcpSyncClientTests.java | 20 +- .../BlaAbstractMcpAsyncServerTests.java | 170 ++- .../server/BlaAbstractMcpSyncServerTests.java | 121 +- .../ai/mcp/WebFluxSseIntegrationTests.java | 74 +- .../mcp/server/WebMvcSseIntegrationTests.java | 73 +- .../ai/mcp/client/McpAsyncClient.java | 194 ++- .../ai/mcp/client/McpClient.java | 464 ++++++- .../ai/mcp/client/McpClientFeatures.java | 200 +++ .../ai/mcp/client/McpSyncClient.java | 31 +- .../ai/mcp/server/McpAsyncServer.java | 385 ++++-- .../ai/mcp/server/McpServer.java | 1091 +++++++++++++++-- .../ai/mcp/server/McpServerFeatures.java | 399 ++++++ .../ai/mcp/server/McpSyncServer.java | 123 +- .../ai/mcp/spec/DefaultMcpSession.java | 10 +- .../client/AbstractMcpAsyncClientTests.java | 46 +- .../client/AbstractMcpSyncClientTests.java | 20 +- .../McpAsyncClientResponseHandlerTests.java | 44 +- .../server/AbstractMcpAsyncServerTests.java | 169 ++- .../server/AbstractMcpSyncServerTests.java | 122 +- ...letSseServerTransportIntegrationTests.java | 50 +- .../ai/mcp/spec/DefaultMcpSessionTests.java | 2 +- .../ai/mcp/spring/ToolHelper.java | 51 + 25 files changed, 3497 insertions(+), 756 deletions(-) create mode 100644 mcp-docs/0.6.0-MIGRATION-GUIDE.md create mode 100644 mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java create mode 100644 mcp/src/main/java/org/springframework/ai/mcp/server/McpServerFeatures.java diff --git a/mcp-docs/0.6.0-MIGRATION-GUIDE.md b/mcp-docs/0.6.0-MIGRATION-GUIDE.md new file mode 100644 index 0000000..37592d0 --- /dev/null +++ b/mcp-docs/0.6.0-MIGRATION-GUIDE.md @@ -0,0 +1,258 @@ +# Spring AI MCP 0.5.0 Migration Guide + +This guide outlines the steps required to migrate your code to Spring AI MCP 0.6.0. + +## Key Changes + +- New builder patterns for `McpClient` and `McpServer` +- Introduction of dedicated feature classes for sync/async operations +- Enhanced type safety and reactive support +- Deprecated methods and classes marked for removal + +## Client Migration + +### Creating Clients + +Before: +```java +// Sync client +McpClient.using(transport) + .requestTimeout(Duration.ofSeconds(5)) + .sync(); + +// Async client +McpClient.using(transport) + .requestTimeout(Duration.ofSeconds(5)) + .async(); +``` + +After: +```java +// Sync client +McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(5)) + .build(); + +// Async client +McpClient.async(transport) + .requestTimeout(Duration.ofSeconds(5)) + .build(); +``` + +### Change Consumers + +Before: +```java +// Sync client +McpClient.using(transport) + .toolsChangeConsumer(tools -> handleTools(tools)) + .resourcesChangeConsumer(resources -> handleResources(resources)) + .promptsChangeConsumer(prompts -> handlePrompts(prompts)) + .sync(); + +// Async client +McpClient.using(transport) + .toolsChangeConsumer(tools -> handleTools(tools)) + .resourcesChangeConsumer(resources -> handleResources(resources)) + .promptsChangeConsumer(prompts -> handlePrompts(prompts)) + .async(); +``` + +After: +```java +// Sync client +McpClient.sync(transport) + .toolsChangeConsumer(tools -> handleTools(tools)) + .resourcesChangeConsumer(resources -> handleResources(resources)) + .promptsChangeConsumer(prompts -> handlePrompts(prompts)) + .build(); + +// Async client +McpClient.async(transport) + .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> handleTools(tools))) + .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> handleResources(resources))) + .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> handlePrompts(prompts))) + .build(); +``` + +### Sampling Handlers + +Before: +```java +// Sync client +McpClient.using(transport) + .sampling(request -> new CreateMessageResult("response")) + .sync(); + +// Async client +McpClient.using(transport) + .sampling(request -> new CreateMessageResult("response")) + .async(); +``` + +After: +```java +// Sync client +McpClient.sync(transport) + .sampling(request -> new CreateMessageResult("response")) + .build(); + +// Async client +McpClient.async(transport) + .sampling(request -> Mono.just(new CreateMessageResult("response"))) + .build(); +``` + +## Server Migration + +### Creating Servers + +Before: +```java +// Sync server +McpServer.using(transport) + .serverInfo("test-server", "1.0.0") + .sync(); + +// Async server +McpServer.using(transport) + .serverInfo("test-server", "1.0.0") + .async(); +``` + +After: +```java +// Sync server +McpServer.sync(transport) + .serverInfo("test-server", "1.0.0") + .build(); + +// Async server +McpServer.async(transport) + .serverInfo("test-server", "1.0.0") + .build(); +``` + +### Tool Registration + +Before: +```java +// Using ToolRegistration record +new ToolRegistration( + new Tool("calculator", "Performs calculations", schema), + args -> new CallToolResult("result") +); +``` + +After: +```java +// Sync server +new McpServerFeatures.SyncToolRegistration( + new Tool("calculator", "Performs calculations", schema), + args -> new CallToolResult("result") +); + +// Async server +new McpServerFeatures.AsyncToolRegistration( + new Tool("calculator", "Performs calculations", schema), + args -> Mono.just(new CallToolResult("result")) +); +``` + +### Resource Registration + +Before: +```java +// Using ResourceRegistration record +new ResourceRegistration( + new Resource("docs", "Documentation", "text/markdown"), + request -> new ReadResourceResult(content) +); +``` + +After: +```java +// Sync server +new McpServerFeatures.SyncResourceRegistration( + new Resource("docs", "Documentation", "text/markdown"), + request -> new ReadResourceResult(content) +); + +// Async server +new McpServerFeatures.AsyncResourceRegistration( + new Resource("docs", "Documentation", "text/markdown"), + request -> Mono.just(new ReadResourceResult(content)) +); +``` + +### Prompt Registration + +Before: +```java +// Using PromptRegistration record +new PromptRegistration( + new Prompt("analyze", "Code analysis"), + request -> new GetPromptResult("result") +); +``` + +After: +```java +// Sync server +new McpServerFeatures.SyncPromptRegistration( + new Prompt("analyze", "Code analysis"), + request -> new GetPromptResult("result") +); + +// Async server +new McpServerFeatures.AsyncPromptRegistration( + new Prompt("analyze", "Code analysis"), + request -> Mono.just(new GetPromptResult("result")) +); +``` + +## Spring Integration Changes + +### Tool Helper Changes + +Before: +```java +ToolHelper.toToolRegistration(functionCallback); +ToolHelper.toToolRegistration(functionCallbacks); +``` + +After: +```java +ToolHelper.toSyncToolRegistration(functionCallback); +ToolHelper.toSyncToolRegistration(functionCallbacks); +``` + +## Deprecated APIs + +The following APIs are deprecated and will be removed in a future release: + +- `McpClient.using()` - Use `McpClient.sync()` or `McpClient.async()` instead +- `McpServer.using()` - Use `McpServer.sync()` or `McpServer.async()` instead +- `McpServer.ToolRegistration` - Use `McpServerFeatures.SyncToolRegistration` or `McpServerFeatures.AsyncToolRegistration` instead +- `McpServer.ResourceRegistration` - Use `McpServerFeatures.SyncResourceRegistration` or `McpServerFeatures.AsyncResourceRegistration` instead +- `McpServer.PromptRegistration` - Use `McpServerFeatures.SyncPromptRegistration` or `McpServerFeatures.AsyncPromptRegistration` instead +- `ToolHelper.toToolRegistration()` - Use `ToolHelper.toSyncToolRegistration()` instead + +## Benefits of Migration + +1. **Improved Type Safety**: The new builder patterns and feature classes provide better compile-time type checking. + +2. **Clear Async/Sync Separation**: Distinct builders and features for sync and async operations make the code intent clearer. + +3. **Enhanced Reactive Support**: Async operations now properly integrate with Project Reactor's `Mono` type. + +4. **Better Error Handling**: More consistent error handling across sync and async operations. + +5. **Simplified Configuration**: Builder patterns provide a more intuitive way to configure clients and servers. + +## Additional Notes + +- The migration primarily focuses on builder patterns and feature organization +- Functional behavior remains the same after migration +- Async operations now properly integrate with Project Reactor +- All deprecated APIs will continue to work but will be removed in a future release 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 9faf8fe..dd3ed6f 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc @@ -129,14 +129,14 @@ Sync API:: [source,java] ---- // Create a sync client with custom configuration -McpSyncClient client = McpClient.using(transport) +McpSyncClient client = McpClient.sync(transport) .requestTimeout(Duration.ofSeconds(10)) .capabilities(ClientCapabilities.builder() .roots(true) // Enable roots capability .sampling() // Enable sampling capability .build()) - .sampling( request -> new CreateMessageResult(response)) - .sync(); + .sampling(request -> new CreateMessageResult(response)) + .build(); // Initialize connection client.initialize(); @@ -175,23 +175,23 @@ Async API:: [source,java] ---- // Create an async client with custom configuration -McpAsyncClient client = McpClient.using(transport) +McpAsyncClient client = McpClient.async(transport) .requestTimeout(Duration.ofSeconds(10)) .capabilities(ClientCapabilities.builder() .roots(true) // Enable roots capability .sampling() // Enable sampling capability .build()) - .sampling( request -> new CreateMessageResult(response)) - .toolsChangeConsumer(tools -> { + .sampling(request -> Mono.just(new CreateMessageResult(response))) + .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> { logger.info("Tools updated: {}", tools); - }) - .resourcesChangeConsumer(resources -> { + })) + .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> { logger.info("Resources updated: {}", resources); - }) - .promptsChangeConsumer(prompts -> { + })) + .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> { logger.info("Prompts updated: {}", prompts); - }) - .async(); + })) + .build(); // Initialize connection client.initialize() @@ -361,13 +361,13 @@ Sync API:: [source,java] ---- // Create a server with custom configuration -McpSyncServer syncServer = McpServer.using(transport) - .info("my-server", "1.0.0") +McpSyncServer syncServer = McpServer.sync(transport) + .serverInfo("my-server", "1.0.0") .capabilities(ServerCapabilities.builder()...build()) - .tools(new CalculatorTool()) - .resources(resourceRegistration) - .prompts(promptRegistration) - .sync(); + .tools(new McpServerFeatures.SyncToolRegistration(calculatorTool, calculatorHandler)) + .resources(new McpServerFeatures.SyncResourceRegistration(resource, resourceHandler)) + .prompts(new McpServerFeatures.SyncPromptRegistration(prompt, promptHandler)) + .build(); // Add a tool handler at runtime syncServer.addTool(new CalculatorTool()); @@ -396,13 +396,13 @@ Async API:: [source,java] ---- // Create an async server with custom configuration -McpAsyncServer asyncServer = McpServer.using(transport) - .info("my-server", "1.0.0") +McpAsyncServer asyncServer = McpServer.async(transport) + .serverInfo("my-server", "1.0.0") .capabilities(ServerCapabilities.builder()...build()) - .tools(new CalculatorTool()) - .resources(resourceRegistration) - .prompts(promptRegistration) - .async(); + .tools(new McpServerFeatures.AsyncToolRegistration(calculatorTool, args -> Mono.just(calculatorHandler.apply(args)))) + .resources(new McpServerFeatures.AsyncResourceRegistration(resource, req -> Mono.just(resourceHandler.apply(req)))) + .prompts(new McpServerFeatures.AsyncPromptRegistration(prompt, req -> Mono.just(promptHandler.apply(req)))) + .build(); // Add a tool handler at runtime asyncServer.addTool(new CalculatorTool()) @@ -593,7 +593,8 @@ Supported logging levels (in order of increasing severity): DEBUG (0), INFO (1), [source,java] ---- -var toolRegistration = new ToolRegistration( +// Sync tool registration +var syncToolRegistration = new McpServerFeatures.SyncToolRegistration( new Tool("calculator", "Basic calculator", Map.of( "operation", "string", "a", "number", @@ -604,26 +605,50 @@ var toolRegistration = new ToolRegistration( return new CallToolResult(result, false); } ); + +// Async tool registration +var asyncToolRegistration = new McpServerFeatures.AsyncToolRegistration( + new Tool("calculator", "Basic calculator", Map.of( + "operation", "string", + "a", "number", + "b", "number" + )), + arguments -> { + // Tool implementation + return Mono.just(new CallToolResult(result, false)); + } +); ---- ==== Resource Registration [source,java] ---- -var resourceRegistration = new ResourceRegistration( +// Sync resource registration +var syncResourceRegistration = new McpServerFeatures.SyncResourceRegistration( new Resource("custom://resource", "name", "description", "mime-type", null), request -> { // Resource read implementation return new ReadResourceResult(contents); } ); + +// Async resource registration +var asyncResourceRegistration = new McpServerFeatures.AsyncResourceRegistration( + new Resource("custom://resource", "name", "description", "mime-type", null), + request -> { + // Resource read implementation + return Mono.just(new ReadResourceResult(contents)); + } +); ---- ==== Prompt Registration [source,java] ---- -var promptRegistration = new PromptRegistration( +// Sync prompt registration +var syncPromptRegistration = new McpServerFeatures.SyncPromptRegistration( new Prompt("greeting", "description", List.of( new PromptArgument("name", "description", true) )), @@ -632,6 +657,17 @@ var promptRegistration = new PromptRegistration( return new GetPromptResult(description, messages); } ); + +// Async prompt registration +var asyncPromptRegistration = new McpServerFeatures.AsyncPromptRegistration( + new Prompt("greeting", "description", List.of( + new PromptArgument("name", "description", true) + )), + request -> { + // Prompt implementation + return Mono.just(new GetPromptResult(description, messages)); + } +); ---- == Error Handling diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpAsyncClientTests.java index 9477f6b..edceae9 100644 --- a/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpAsyncClientTests.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import org.springframework.ai.mcp.spec.ClientMcpTransport; @@ -77,10 +78,10 @@ public abstract class BlaAbstractMcpAsyncClientTests { this.mcpTransport = createMcpTransport(); assertThatCode(() -> { - mcpAsyncClient = McpClient.using(mcpTransport) + mcpAsyncClient = McpClient.async(mcpTransport) .requestTimeout(TIMEOUT) .capabilities(ClientCapabilities.builder().roots(true).build()) - .async(); + .build(); mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); }).doesNotThrowAnyException(); } @@ -96,10 +97,10 @@ public abstract class BlaAbstractMcpAsyncClientTests { @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync()) + assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } @@ -195,10 +196,10 @@ public abstract class BlaAbstractMcpAsyncClientTests { void testInitializeWithRootsListProviders() { var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.async(transport) .requestTimeout(TIMEOUT) .roots(new Root("file:///test/path", "test-root")) - .async(); + .build(); assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); @@ -277,12 +278,12 @@ public abstract class BlaAbstractMcpAsyncClientTests { AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.async(transport) .requestTimeout(TIMEOUT) - .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) - .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) - .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) - .async(); + .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(); @@ -299,9 +300,11 @@ public abstract class BlaAbstractMcpAsyncClientTests { var capabilities = ClientCapabilities.builder().sampling().build(); - var client = McpClient.using(transport).requestTimeout(TIMEOUT).capabilities(capabilities).sampling(request -> { - return CreateMessageResult.builder().message("test").model("test-model").build(); - }).async(); + var client = McpClient.async(transport) + .requestTimeout(TIMEOUT) + .capabilities(capabilities) + .sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build())) + .build(); assertThatCode(() -> { client.initialize().block(Duration.ofSeconds(10)); @@ -319,14 +322,13 @@ public abstract class BlaAbstractMcpAsyncClientTests { .sampling() .build(); - Function samplingHandler = request -> { - return CreateMessageResult.builder().message("test").model("test-model").build(); - }; - var client = McpClient.using(transport) + Function> samplingHandler = request -> Mono + .just(CreateMessageResult.builder().message("test").model("test-model").build()); + var client = McpClient.async(transport) .requestTimeout(TIMEOUT) .capabilities(capabilities) .sampling(samplingHandler) - .async(); + .build(); assertThatCode(() -> { var result = client.initialize().block(Duration.ofSeconds(10)); @@ -353,10 +355,10 @@ public abstract class BlaAbstractMcpAsyncClientTests { AtomicBoolean logReceived = new AtomicBoolean(false); var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.async(transport) .requestTimeout(TIMEOUT) - .loggingConsumer(notification -> logReceived.set(true)) - .async(); + .loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))) + .build(); assertThatCode(() -> { client.initialize().block(Duration.ofSeconds(10)); diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpSyncClientTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpSyncClientTests.java index 59d1896..26e453c 100644 --- a/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpSyncClientTests.java @@ -72,10 +72,10 @@ public abstract class BlaAbstractMcpSyncClientTests { this.mcpTransport = createMcpTransport(); assertThatCode(() -> { - mcpSyncClient = McpClient.using(mcpTransport) + mcpSyncClient = McpClient.sync(mcpTransport) .requestTimeout(TIMEOUT) .capabilities(ClientCapabilities.builder().roots(true).build()) - .sync(); + .build(); mcpSyncClient.initialize(); }).doesNotThrowAnyException(); } @@ -90,10 +90,10 @@ public abstract class BlaAbstractMcpSyncClientTests { @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync()) + assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } @@ -180,10 +180,10 @@ public abstract class BlaAbstractMcpSyncClientTests { void testInitializeWithRootsListProviders() { var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.sync(transport) .requestTimeout(TIMEOUT) .roots(new Root("file:///test/path", "test-root")) - .sync(); + .build(); assertThatCode(() -> { client.initialize(); @@ -262,12 +262,12 @@ public abstract class BlaAbstractMcpSyncClientTests { AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.sync(transport) .requestTimeout(TIMEOUT) .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) - .sync(); + .build(); assertThatCode(() -> { client.initialize(); @@ -295,10 +295,10 @@ public abstract class BlaAbstractMcpSyncClientTests { AtomicBoolean logReceived = new AtomicBoolean(false); var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.sync(transport) .requestTimeout(TIMEOUT) .loggingConsumer(notification -> logReceived.set(true)) - .sync(); + .build(); assertThatCode(() -> { client.initialize(); diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpAsyncServerTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpAsyncServerTests.java index d765e05..807bcc5 100644 --- a/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpAsyncServerTests.java +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpAsyncServerTests.java @@ -18,16 +18,13 @@ package org.springframework.ai.mcp.server; import java.time.Duration; import java.util.List; -import java.util.Map; 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.test.StepVerifier; -import org.springframework.ai.mcp.server.McpServer.PromptRegistration; -import org.springframework.ai.mcp.server.McpServer.ResourceRegistration; -import org.springframework.ai.mcp.server.McpServer.ToolRegistration; import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; @@ -82,24 +79,24 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpServer.async(null)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) + assertThatThrownBy(() -> McpServer.async(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Server info must not be null"); } @Test void testGracefulShutdown() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete(); } @Test void testImmediateClose() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException(); } @@ -118,81 +115,80 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testAddTool() { Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .async(); + .build(); - StepVerifier - .create(mcpAsyncServer.addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false)))) + StepVerifier.create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolRegistration(newTool, + args -> Mono.just(new CallToolResult(List.of(), false))))) .verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testAddDuplicateTool() { Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(duplicateTool, args -> new CallToolResult(List.of(), false)) - .async(); + .tool(duplicateTool, args -> Mono.just(new CallToolResult(List.of(), false))) + .build(); - StepVerifier - .create(mcpAsyncServer - .addTool(new ToolRegistration(duplicateTool, args -> new CallToolResult(List.of(), false)))) + StepVerifier.create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolRegistration(duplicateTool, + args -> Mono.just(new CallToolResult(List.of(), false))))) .verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists"); }); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testRemoveTool() { Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(too, args -> new CallToolResult(List.of(), false)) - .async(); + .tool(too, args -> Mono.just(new CallToolResult(List.of(), false))) + .build(); StepVerifier.create(mcpAsyncServer.removeTool(TEST_TOOL_NAME)).verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testRemoveNonexistentTool() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .async(); + .build(); StepVerifier.create(mcpAsyncServer.removeTool("nonexistent-tool")).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class).hasMessage("Tool with name 'nonexistent-tool' not found"); }); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testNotifyToolsListChanged() { Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(too, args -> new CallToolResult(List.of(), false)) - .async(); + .tool(too, args -> Mono.just(new CallToolResult(List.of(), false))) + .build(); StepVerifier.create(mcpAsyncServer.notifyToolsListChanged()).verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } // --------------------------------------- @@ -201,24 +197,24 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testNotifyResourcesListChanged() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testAddResource() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .async(); + .build(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", null); - ResourceRegistration registration = new ResourceRegistration(resource, - req -> new ReadResourceResult(List.of())); + McpServerFeatures.AsyncResourceRegistration registration = new McpServerFeatures.AsyncResourceRegistration( + resource, req -> Mono.just(new ReadResourceResult(List.of()))); StepVerifier.create(mcpAsyncServer.addResource(registration)).verifyComplete(); @@ -227,14 +223,15 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testAddResourceWithNullRegistration() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .async(); + .build(); - StepVerifier.create(mcpAsyncServer.addResource(null)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource must not be null"); - }); + StepVerifier.create(mcpAsyncServer.addResource((McpServerFeatures.AsyncResourceRegistration) null)) + .verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource must not be null"); + }); assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @@ -242,14 +239,14 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testAddResourceWithoutCapability() { // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport()) + McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .async(); + .build(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", null); - ResourceRegistration registration = new ResourceRegistration(resource, - req -> new ReadResourceResult(List.of())); + McpServerFeatures.AsyncResourceRegistration registration = new McpServerFeatures.AsyncResourceRegistration( + resource, req -> Mono.just(new ReadResourceResult(List.of()))); StepVerifier.create(serverWithoutResources.addResource(registration)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -260,9 +257,9 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testRemoveResourceWithoutCapability() { // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport()) + McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .async(); + .build(); StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -276,36 +273,37 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testNotifyPromptsListChanged() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testAddPromptWithNullRegistration() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(false).build()) - .async(); + .build(); - StepVerifier.create(mcpAsyncServer.addPrompt(null)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null"); - }); + StepVerifier.create(mcpAsyncServer.addPrompt((McpServerFeatures.AsyncPromptRegistration) null)) + .verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null"); + }); } @Test void testAddPromptWithoutCapability() { // Create a server without prompt capabilities - McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport()) + McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .async(); + .build(); Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); - PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( - "Test prompt description", - List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + McpServerFeatures.AsyncPromptRegistration registration = new McpServerFeatures.AsyncPromptRegistration(prompt, + req -> Mono.just(new GetPromptResult("Test prompt description", List + .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))))); StepVerifier.create(serverWithoutPrompts.addPrompt(registration)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -316,9 +314,9 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testRemovePromptWithoutCapability() { // Create a server without prompt capabilities - McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport()) + McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .async(); + .build(); StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -331,15 +329,15 @@ public abstract class BlaAbstractMcpAsyncServerTests { String TEST_PROMPT_NAME_TO_REMOVE = "TEST_PROMPT_NAME678"; Prompt prompt = new Prompt(TEST_PROMPT_NAME_TO_REMOVE, "Test Prompt", List.of()); - PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( - "Test prompt description", - List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + McpServerFeatures.AsyncPromptRegistration registration = new McpServerFeatures.AsyncPromptRegistration(prompt, + req -> Mono.just(new GetPromptResult("Test prompt description", List + .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))))); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) .prompts(registration) - .async(); + .build(); StepVerifier.create(mcpAsyncServer.removePrompt(TEST_PROMPT_NAME_TO_REMOVE)).verifyComplete(); @@ -348,10 +346,10 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testRemoveNonexistentPrompt() { - var mcpAsyncServer2 = McpServer.using(createMcpTransport()) + var mcpAsyncServer2 = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) - .async(); + .build(); StepVerifier.create(mcpAsyncServer2.removePrompt("nonexistent-prompt")).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -372,15 +370,15 @@ public abstract class BlaAbstractMcpAsyncServerTests { var rootsReceived = new McpSchema.Root[1]; var consumerCalled = new boolean[1]; - var singleConsumerServer = McpServer.using(createMcpTransport()) + var singleConsumerServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .rootsChangeConsumers(List.of(roots -> { + .rootsChangeConsumers(List.of(roots -> Mono.fromRunnable(() -> { consumerCalled[0] = true; if (!roots.isEmpty()) { rootsReceived[0] = roots.get(0); } - })) - .async(); + }))) + .build(); assertThat(singleConsumerServer).isNotNull(); assertThatCode(() -> singleConsumerServer.closeGracefully().block(Duration.ofSeconds(10))) @@ -392,13 +390,13 @@ public abstract class BlaAbstractMcpAsyncServerTests { var consumer2Called = new boolean[1]; var rootsContent = new List[1]; - var multipleConsumersServer = McpServer.using(createMcpTransport()) + var multipleConsumersServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .rootsChangeConsumers(List.of(roots -> { + .rootsChangeConsumers(List.of(roots -> Mono.fromRunnable(() -> { consumer1Called[0] = true; rootsContent[0] = roots; - }, roots -> consumer2Called[0] = true)) - .async(); + }), roots -> Mono.fromRunnable(() -> consumer2Called[0] = true))) + .build(); assertThat(multipleConsumersServer).isNotNull(); assertThatCode(() -> multipleConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) @@ -406,12 +404,12 @@ public abstract class BlaAbstractMcpAsyncServerTests { onClose(); // Test error handling - var errorHandlingServer = McpServer.using(createMcpTransport()) + var errorHandlingServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .rootsChangeConsumers(List.of(roots -> { throw new RuntimeException("Test error"); })) - .async(); + .build(); assertThat(errorHandlingServer).isNotNull(); assertThatCode(() -> errorHandlingServer.closeGracefully().block(Duration.ofSeconds(10))) @@ -419,7 +417,7 @@ public abstract class BlaAbstractMcpAsyncServerTests { onClose(); // Test without consumers - var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var noConsumersServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThat(noConsumersServer).isNotNull(); assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) @@ -432,10 +430,10 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testLoggingLevels() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) - .async(); + .build(); // Test all logging levels for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { @@ -451,10 +449,10 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testLoggingWithoutCapability() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) // No logging capability - .async(); + .build(); var notification = McpSchema.LoggingMessageNotification.builder() .level(McpSchema.LoggingLevel.INFO) @@ -467,10 +465,10 @@ public abstract class BlaAbstractMcpAsyncServerTests { @Test void testLoggingWithNullNotification() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) - .async(); + .build(); StepVerifier.create(mcpAsyncServer.loggingNotification(null)).verifyError(McpError.class); } diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpSyncServerTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpSyncServerTests.java index 069dc83..415f08e 100644 --- a/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpSyncServerTests.java +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpSyncServerTests.java @@ -23,9 +23,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.ai.mcp.server.McpServer.PromptRegistration; -import org.springframework.ai.mcp.server.McpServer.ResourceRegistration; -import org.springframework.ai.mcp.server.McpServer.ToolRegistration; import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; @@ -81,31 +78,31 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpServer.sync(null)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) + assertThatThrownBy(() -> McpServer.sync(createMcpTransport()).serverInfo(null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Server info must not be null"); } @Test void testGracefulShutdown() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); } @Test void testImmediateClose() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException(); } @Test void testGetAsyncServer() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThat(mcpSyncServer.getAsyncServer()).isNotNull(); @@ -126,14 +123,14 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testAddTool() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .sync(); + .build(); Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema); assertThatCode(() -> mcpSyncServer - .addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false)))) + .addTool(new McpServerFeatures.SyncToolRegistration(newTool, args -> new CallToolResult(List.of(), false)))) .doesNotThrowAnyException(); assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); @@ -143,14 +140,14 @@ public abstract class BlaAbstractMcpSyncServerTests { void testAddDuplicateTool() { Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tool(duplicateTool, args -> new CallToolResult(List.of(), false)) - .sync(); + .build(); - assertThatThrownBy(() -> mcpSyncServer - .addTool(new ToolRegistration(duplicateTool, args -> new CallToolResult(List.of(), false)))) + assertThatThrownBy(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolRegistration(duplicateTool, + args -> new CallToolResult(List.of(), false)))) .isInstanceOf(McpError.class) .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists"); @@ -161,11 +158,11 @@ public abstract class BlaAbstractMcpSyncServerTests { void testRemoveTool() { Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", emptyJsonSchema); - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tool(tool, args -> new CallToolResult(List.of(), false)) - .sync(); + .build(); assertThatCode(() -> mcpSyncServer.removeTool(TEST_TOOL_NAME)).doesNotThrowAnyException(); @@ -174,10 +171,10 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testRemoveNonexistentTool() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .sync(); + .build(); assertThatThrownBy(() -> mcpSyncServer.removeTool("nonexistent-tool")).isInstanceOf(McpError.class) .hasMessage("Tool with name 'nonexistent-tool' not found"); @@ -187,7 +184,7 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testNotifyToolsListChanged() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.notifyToolsListChanged()).doesNotThrowAnyException(); @@ -200,7 +197,7 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testNotifyResourcesListChanged() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.notifyResourcesListChanged()).doesNotThrowAnyException(); @@ -209,15 +206,15 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testAddResource() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .sync(); + .build(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", null); - ResourceRegistration registration = new ResourceRegistration(resource, - req -> new ReadResourceResult(List.of())); + McpServerFeatures.SyncResourceRegistration registration = new McpServerFeatures.SyncResourceRegistration( + resource, req -> new ReadResourceResult(List.of())); assertThatCode(() -> mcpSyncServer.addResource(registration)).doesNotThrowAnyException(); @@ -226,12 +223,13 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testAddResourceWithNullRegistration() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .sync(); + .build(); - assertThatThrownBy(() -> mcpSyncServer.addResource(null)).isInstanceOf(McpError.class) + assertThatThrownBy(() -> mcpSyncServer.addResource((McpServerFeatures.SyncResourceRegistration) null)) + .isInstanceOf(McpError.class) .hasMessage("Resource must not be null"); assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); @@ -239,12 +237,12 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testAddResourceWithoutCapability() { - var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var serverWithoutResources = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", null); - ResourceRegistration registration = new ResourceRegistration(resource, - req -> new ReadResourceResult(List.of())); + McpServerFeatures.SyncResourceRegistration registration = new McpServerFeatures.SyncResourceRegistration( + resource, req -> new ReadResourceResult(List.of())); assertThatThrownBy(() -> serverWithoutResources.addResource(registration)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with resource capabilities"); @@ -252,7 +250,7 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testRemoveResourceWithoutCapability() { - var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var serverWithoutResources = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with resource capabilities"); @@ -264,7 +262,7 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testNotifyPromptsListChanged() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException(); @@ -273,23 +271,24 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testAddPromptWithNullRegistration() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(false).build()) - .sync(); + .build(); - assertThatThrownBy(() -> mcpSyncServer.addPrompt(null)).isInstanceOf(McpError.class) + assertThatThrownBy(() -> mcpSyncServer.addPrompt((McpServerFeatures.SyncPromptRegistration) null)) + .isInstanceOf(McpError.class) .hasMessage("Prompt registration must not be null"); } @Test void testAddPromptWithoutCapability() { - var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var serverWithoutPrompts = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); - PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( - "Test prompt description", - List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + McpServerFeatures.SyncPromptRegistration registration = new McpServerFeatures.SyncPromptRegistration(prompt, + req -> new GetPromptResult("Test prompt description", List + .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(registration)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with prompt capabilities"); @@ -297,7 +296,7 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testRemovePromptWithoutCapability() { - var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var serverWithoutPrompts = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with prompt capabilities"); @@ -306,15 +305,15 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testRemovePrompt() { Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); - PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( - "Test prompt description", - List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + McpServerFeatures.SyncPromptRegistration registration = new McpServerFeatures.SyncPromptRegistration(prompt, + req -> new GetPromptResult("Test prompt description", List + .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) .prompts(registration) - .sync(); + .build(); assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException(); @@ -323,10 +322,10 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testRemoveNonexistentPrompt() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) - .sync(); + .build(); assertThatThrownBy(() -> mcpSyncServer.removePrompt("nonexistent-prompt")).isInstanceOf(McpError.class) .hasMessage("Prompt with name 'nonexistent-prompt' not found"); @@ -344,7 +343,7 @@ public abstract class BlaAbstractMcpSyncServerTests { var rootsReceived = new McpSchema.Root[1]; var consumerCalled = new boolean[1]; - var singleConsumerServer = McpServer.using(createMcpTransport()) + var singleConsumerServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .rootsChangeConsumers(List.of(roots -> { consumerCalled[0] = true; @@ -352,7 +351,7 @@ public abstract class BlaAbstractMcpSyncServerTests { rootsReceived[0] = roots.get(0); } })) - .sync(); + .build(); assertThat(singleConsumerServer).isNotNull(); assertThatCode(() -> singleConsumerServer.closeGracefully()).doesNotThrowAnyException(); @@ -363,32 +362,32 @@ public abstract class BlaAbstractMcpSyncServerTests { var consumer2Called = new boolean[1]; var rootsContent = new List[1]; - var multipleConsumersServer = McpServer.using(createMcpTransport()) + var multipleConsumersServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .rootsChangeConsumers(List.of(roots -> { consumer1Called[0] = true; rootsContent[0] = roots; }, roots -> consumer2Called[0] = true)) - .sync(); + .build(); assertThat(multipleConsumersServer).isNotNull(); assertThatCode(() -> multipleConsumersServer.closeGracefully()).doesNotThrowAnyException(); onClose(); // Test error handling - var errorHandlingServer = McpServer.using(createMcpTransport()) + var errorHandlingServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .rootsChangeConsumers(List.of(roots -> { throw new RuntimeException("Test error"); })) - .sync(); + .build(); assertThat(errorHandlingServer).isNotNull(); assertThatCode(() -> errorHandlingServer.closeGracefully()).doesNotThrowAnyException(); onClose(); // Test without consumers - var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var noConsumersServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThat(noConsumersServer).isNotNull(); assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException(); @@ -400,10 +399,10 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testLoggingLevels() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) - .sync(); + .build(); // Test all logging levels for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { @@ -419,10 +418,10 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testLoggingWithoutCapability() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) // No logging capability - .sync(); + .build(); var notification = McpSchema.LoggingMessageNotification.builder() .level(McpSchema.LoggingLevel.INFO) @@ -435,10 +434,10 @@ public abstract class BlaAbstractMcpSyncServerTests { @Test void testLoggingWithNullNotification() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) - .sync(); + .build(); assertThatThrownBy(() -> mcpSyncServer.loggingNotification(null)).isInstanceOf(McpError.class); } diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/WebFluxSseIntegrationTests.java b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/WebFluxSseIntegrationTests.java index 1e3c11a..ca968cf 100644 --- a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/WebFluxSseIntegrationTests.java +++ b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/WebFluxSseIntegrationTests.java @@ -36,7 +36,7 @@ import org.springframework.ai.mcp.client.McpClient; import org.springframework.ai.mcp.client.transport.HttpClientSseClientTransport; import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport; import org.springframework.ai.mcp.server.McpServer; -import org.springframework.ai.mcp.server.McpServer.ToolRegistration; +import org.springframework.ai.mcp.server.McpServerFeatures; import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport; import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; @@ -69,7 +69,7 @@ public class WebFluxSseIntegrationTests { private WebFluxSseServerTransport mcpServerTransport; - ConcurrentHashMap clientBulders = new ConcurrentHashMap<>(); + ConcurrentHashMap clientBulders = new ConcurrentHashMap<>(); @BeforeEach public void before() { @@ -80,9 +80,9 @@ public class WebFluxSseIntegrationTests { ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); - clientBulders.put("httpclient", McpClient.using(new HttpClientSseClientTransport("http://localhost:" + PORT))); - clientBulders.put("webflux", McpClient - .using(new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:" + PORT)))); + clientBulders.put("httpclient", McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT))); + clientBulders.put("webflux", + McpClient.sync(new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:" + PORT)))); } @@ -98,7 +98,7 @@ public class WebFluxSseIntegrationTests { // --------------------------------------- @Test void testCreateMessageWithoutInitialization() { - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + 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"))); @@ -117,11 +117,11 @@ public class WebFluxSseIntegrationTests { @ValueSource(strings = { "httpclient", "webflux" }) void testCreateMessageWithoutSamplingCapabilities(String clientType) { - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + 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")).sync(); + var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build(); InitializeResult initResult = client.initialize(); assertThat(initResult).isNotNull(); @@ -145,7 +145,7 @@ public class WebFluxSseIntegrationTests { var clientBuilder = clientBulders.get(clientType); - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); Function samplingHandler = request -> { assertThat(request.messages()).hasSize(1); @@ -158,7 +158,7 @@ public class WebFluxSseIntegrationTests { var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) - .sync(); + .build(); InitializeResult initResult = client.initialize(); assertThat(initResult).isNotNull(); @@ -191,13 +191,13 @@ public class WebFluxSseIntegrationTests { List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) - .sync(); + .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -236,14 +236,14 @@ public class WebFluxSseIntegrationTests { void testRootsWithoutCapability(String clientType) { var clientBuilder = clientBulders.get(clientType); - var mcpServer = McpServer.using(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> { - }).sync(); + var mcpServer = McpServer.sync(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> { + }).build(); // Create client without roots capability var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()) // No // roots // capability - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -262,13 +262,13 @@ public class WebFluxSseIntegrationTests { var clientBuilder = clientBulders.get(clientType); AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) - .sync(); + .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(List.of()) // Empty roots list - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -293,14 +293,14 @@ public class WebFluxSseIntegrationTests { AtomicReference> rootsRef1 = new AtomicReference<>(); AtomicReference> rootsRef2 = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate)) .rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate)) - .sync(); + .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -325,13 +325,13 @@ public class WebFluxSseIntegrationTests { List roots = List.of(new Root("uri1://", "root1")); AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) - .sync(); + .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -368,8 +368,8 @@ public class WebFluxSseIntegrationTests { var clientBuilder = clientBulders.get(clientType); var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); - ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), - request -> { + McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> { // perform a blocking call to a remote service String response = RestClient.create() .get() @@ -380,12 +380,12 @@ public class WebFluxSseIntegrationTests { return callResponse; }); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) - .sync(); + .build(); - var mcpClient = clientBuilder.sync(); + var mcpClient = clientBuilder.build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -408,8 +408,8 @@ public class WebFluxSseIntegrationTests { var clientBuilder = clientBulders.get(clientType); var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); - ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), - request -> { + McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> { // perform a blocking call to a remote service String response = RestClient.create() .get() @@ -420,10 +420,10 @@ public class WebFluxSseIntegrationTests { return callResponse; }); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) - .sync(); + .build(); AtomicReference> rootsRef = new AtomicReference<>(); var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { @@ -435,7 +435,7 @@ public class WebFluxSseIntegrationTests { .body(String.class); assertThat(response).isNotBlank(); rootsRef.set(toolsUpdate); - }).sync(); + }).build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -458,8 +458,8 @@ public class WebFluxSseIntegrationTests { }); // Add a new tool - ToolRegistration tool2 = new ToolRegistration(new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), - request -> callResponse); + McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration( + new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse); mcpServer.addTool(tool2); diff --git a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseIntegrationTests.java b/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseIntegrationTests.java index 6658386..308be78 100644 --- a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseIntegrationTests.java +++ b/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseIntegrationTests.java @@ -35,7 +35,6 @@ import reactor.test.StepVerifier; import org.springframework.ai.mcp.client.McpClient; import org.springframework.ai.mcp.client.transport.HttpClientSseClientTransport; -import org.springframework.ai.mcp.server.McpServer.ToolRegistration; import org.springframework.ai.mcp.server.transport.WebMvcSseServerTransport; import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; @@ -71,7 +70,7 @@ public class WebMvcSseIntegrationTests { private WebMvcSseServerTransport mcpServerTransport; - McpClient.Builder clientBuilder; + McpClient.SyncSpec clientBuilder; @Configuration @EnableWebMvc @@ -137,7 +136,7 @@ public class WebMvcSseIntegrationTests { throw new RuntimeException("Failed to start Tomcat", e); } - this.clientBuilder = McpClient.using(new HttpClientSseClientTransport("http://localhost:" + PORT)); + this.clientBuilder = McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT)); } @AfterEach @@ -164,7 +163,7 @@ public class WebMvcSseIntegrationTests { // --------------------------------------- @Test void testCreateMessageWithoutInitialization() { - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + 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"))); @@ -182,9 +181,9 @@ public class WebMvcSseIntegrationTests { @Test void testCreateMessageWithoutSamplingCapabilities() { - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).sync(); + var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build(); InitializeResult initResult = client.initialize(); assertThat(initResult).isNotNull(); @@ -205,7 +204,7 @@ public class WebMvcSseIntegrationTests { @Test void testCreateMessageSuccess() throws InterruptedException { - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); Function samplingHandler = request -> { assertThat(request.messages()).hasSize(1); @@ -218,7 +217,7 @@ public class WebMvcSseIntegrationTests { var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) - .sync(); + .build(); InitializeResult initResult = client.initialize(); assertThat(initResult).isNotNull(); @@ -248,13 +247,13 @@ public class WebMvcSseIntegrationTests { List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) - .sync(); + .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -290,12 +289,12 @@ public class WebMvcSseIntegrationTests { @Test void testRootsWithoutCapability() { - var mcpServer = McpServer.using(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> { - }).sync(); + var mcpServer = McpServer.sync(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> { + }).build(); // Create client without roots capability // No roots capability - var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).sync(); + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -311,13 +310,13 @@ public class WebMvcSseIntegrationTests { @Test void testRootsWithEmptyRootsList() { AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) - .sync(); + .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(List.of()) // Empty roots list - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -339,14 +338,14 @@ public class WebMvcSseIntegrationTests { AtomicReference> rootsRef1 = new AtomicReference<>(); AtomicReference> rootsRef2 = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate)) .rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate)) - .sync(); + .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -367,13 +366,13 @@ public class WebMvcSseIntegrationTests { List roots = List.of(new Root("uri1://", "root1")); AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) - .sync(); + .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -407,8 +406,8 @@ public class WebMvcSseIntegrationTests { 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", emptyJsonSchema), - request -> { + McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> { // perform a blocking call to a remote service String response = RestClient.create() .get() @@ -419,12 +418,12 @@ public class WebMvcSseIntegrationTests { return callResponse; }); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) - .sync(); + .build(); - var mcpClient = clientBuilder.sync(); + var mcpClient = clientBuilder.build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -444,8 +443,8 @@ public class WebMvcSseIntegrationTests { 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", emptyJsonSchema), - request -> { + McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> { // perform a blocking call to a remote service String response = RestClient.create() .get() @@ -456,10 +455,10 @@ public class WebMvcSseIntegrationTests { return callResponse; }); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) - .sync(); + .build(); AtomicReference> rootsRef = new AtomicReference<>(); var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { @@ -471,7 +470,7 @@ public class WebMvcSseIntegrationTests { .body(String.class); assertThat(response).isNotBlank(); rootsRef.set(toolsUpdate); - }).sync(); + }).build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -494,8 +493,8 @@ public class WebMvcSseIntegrationTests { }); // Add a new tool - ToolRegistration tool2 = new ToolRegistration(new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), - request -> callResponse); + McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration( + new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse); mcpServer.addTool(tool2); @@ -510,9 +509,9 @@ public class WebMvcSseIntegrationTests { @Test void testInitialize() { - var mcpServer = McpServer.using(mcpServerTransport).sync(); + var mcpServer = McpServer.sync(mcpServerTransport).build(); - var mcpClient = clientBuilder.sync(); + var mcpClient = clientBuilder.build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java index 7b7958a..22d0804 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java @@ -27,6 +27,7 @@ import java.util.function.Function; import com.fasterxml.jackson.core.type.TypeReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; @@ -55,10 +56,39 @@ import org.springframework.ai.mcp.util.Utils; /** * The Model Context Protocol (MCP) client implementation that provides asynchronous - * communication with MCP servers. + * communication with MCP servers using Project Reactor's Mono and Flux types. + * + *

+ * This client implements the MCP specification, enabling AI models to interact with + * external tools and resources through a standardized interface. Key features include: + *

    + *
  • Asynchronous communication using reactive programming patterns + *
  • Tool discovery and invocation for server-provided functionality + *
  • Resource access and management with URI-based addressing + *
  • Prompt template handling for standardized AI interactions + *
  • Real-time notifications for tools, resources, and prompts changes + *
  • Structured logging with configurable severity levels + *
  • Message sampling for AI model interactions + *
+ * + *

+ * The client follows a lifecycle: + *

    + *
  1. Initialization - Establishes connection and negotiates capabilities + *
  2. Normal Operation - Handles requests and notifications + *
  3. Graceful Shutdown - Ensures clean connection termination + *
+ * + *

+ * This implementation uses Project Reactor for non-blocking operations, making it + * suitable for high-throughput scenarios and reactive applications. All operations return + * Mono or Flux types that can be composed into reactive pipelines. * * @author Dariusz Jędrzejczyk * @author Christian Tzolov + * @see McpClient + * @see McpSchema + * @see DefaultMcpSession */ public class McpAsyncClient { @@ -109,13 +139,96 @@ public class McpAsyncClient { * request text or image-based interactions and optionally include context from MCP * servers in their prompts. */ - private Function samplingHandler; + private Function> samplingHandler; /** * Client transport implementation. */ private final McpTransport transport; + /** + * Create a new McpAsyncClient with the given transport and session request-response + * timeout. + * @param transport the transport to use. + * @param requestTimeout the session request-response timeout. + * @param features the MCP Client supported features. + */ + McpAsyncClient(ClientMcpTransport transport, Duration requestTimeout, McpClientFeatures.Async features) { + + Assert.notNull(transport, "Transport must not be null"); + Assert.notNull(requestTimeout, "Request timeout must not be null"); + + this.clientInfo = features.clientInfo(); + this.clientCapabilities = features.clientCapabilities(); + this.transport = transport; + this.roots = new ConcurrentHashMap<>(features.roots()); + + // Request Handlers + Map> requestHandlers = new HashMap<>(); + + // Roots List Request Handler + if (this.clientCapabilities.roots() != null) { + requestHandlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler()); + } + + // Sampling Handler + if (this.clientCapabilities.sampling() != null) { + if (features.samplingHandler() == null) { + throw new McpError("Sampling handler must not be null when client capabilities include sampling"); + } + this.samplingHandler = features.samplingHandler(); + requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler()); + } + + // Notification Handlers + Map notificationHandlers = new HashMap<>(); + + // Tools Change Notification + List, Mono>> toolsChangeConsumersFinal = new ArrayList<>(); + toolsChangeConsumersFinal + .add((notification) -> Mono.fromRunnable(() -> logger.info("Tools changed: {}", notification))); + + if (!Utils.isEmpty(features.toolsChangeConsumers())) { + toolsChangeConsumersFinal.addAll(features.toolsChangeConsumers()); + } + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, + asyncToolsChangeNotificationHandler(toolsChangeConsumersFinal)); + + // Resources Change Notification + List, Mono>> resourcesChangeConsumersFinal = new ArrayList<>(); + resourcesChangeConsumersFinal + .add((notification) -> Mono.fromRunnable(() -> logger.info("Resources changed: {}", notification))); + + if (!Utils.isEmpty(features.resourcesChangeConsumers())) { + resourcesChangeConsumersFinal.addAll(features.resourcesChangeConsumers()); + } + + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, + asyncResourcesChangeNotificationHandler(resourcesChangeConsumersFinal)); + + // Prompts Change Notification + List, Mono>> promptsChangeConsumersFinal = new ArrayList<>(); + promptsChangeConsumersFinal + .add((notification) -> Mono.fromRunnable(() -> logger.info("Prompts changed: {}", notification))); + if (!Utils.isEmpty(features.promptsChangeConsumers())) { + promptsChangeConsumersFinal.addAll(features.promptsChangeConsumers()); + } + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, + asyncPromptsChangeNotificationHandler(promptsChangeConsumersFinal)); + + // Utility Logging Notification + List>> loggingConsumersFinal = new ArrayList<>(); + loggingConsumersFinal.add((notification) -> Mono.fromRunnable(() -> logger.info("Logging: {}", notification))); + if (!Utils.isEmpty(features.loggingConsumers())) { + loggingConsumersFinal.addAll(features.loggingConsumers()); + } + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE, + asyncLoggingNotificationHandler(loggingConsumersFinal)); + + this.mcpSession = new DefaultMcpSession(requestTimeout, transport, requestHandlers, notificationHandlers); + + } + /** * Create a new McpAsyncClient with the given transport and session request-response * timeout. @@ -129,7 +242,9 @@ public class McpAsyncClient { * @param promptsChangeConsumers the prompts change consumers. * @param loggingConsumers the logging consumers. * @param samplingHandler the sampling handler. + * @deprecated Use {@link McpClient#async(ClientMcpTransport)} to obtain an instance. */ + @Deprecated public McpAsyncClient(ClientMcpTransport transport, Duration requestTimeout, Implementation clientInfo, ClientCapabilities clientCapabilities, Map roots, List>> toolsChangeConsumers, @@ -153,11 +268,11 @@ public class McpAsyncClient { this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>(); // Request Handlers - Map requestHanlers = new HashMap<>(); + Map> requestHandlers = new HashMap<>(); // Roots List Request Handler - if (this.roots != null && this.clientCapabilities.roots() != null) { - requestHanlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler()); + if (this.clientCapabilities.roots() != null) { + requestHandlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler()); } // Sampling Handler @@ -165,8 +280,9 @@ public class McpAsyncClient { if (samplingHandler == null) { throw new McpError("Sampling handler must not be null when client capabilities include sampling"); } - this.samplingHandler = samplingHandler; - requestHanlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler()); + this.samplingHandler = r -> Mono.fromCallable(() -> samplingHandler.apply(r)) + .subscribeOn(Schedulers.boundedElastic()); + requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler()); } // Notification Handlers @@ -208,7 +324,7 @@ public class McpAsyncClient { notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE, loggingNotificationHandler(loggingConsumersFinal)); - this.mcpSession = new DefaultMcpSession(requestTimeout, transport, requestHanlers, notificationHandlers); + this.mcpSession = new DefaultMcpSession(requestTimeout, transport, requestHandlers, notificationHandlers); } @@ -416,15 +532,13 @@ public class McpAsyncClient { // -------------------------- // Sampling // -------------------------- - private RequestHandler samplingCreateMessageHandler() { + private RequestHandler samplingCreateMessageHandler() { return params -> { McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params, new TypeReference() { }); - CreateMessageResult response = this.samplingHandler.apply(request); - - return Mono.just(response); + return this.samplingHandler.apply(request); }; } @@ -500,6 +614,26 @@ public class McpAsyncClient { }).then(); // Convert to Mono } + /** + * 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())) + .then()); + } + // -------------------------- // Resources // -------------------------- @@ -621,6 +755,13 @@ public class McpAsyncClient { }).then(); } + private NotificationHandler asyncResourcesChangeNotificationHandler( + List, Mono>> resourcesChangeConsumers) { + return params -> listResources().flatMap(listResourcesResult -> Flux.fromIterable(resourcesChangeConsumers) + .flatMap(consumer -> consumer.apply(listResourcesResult.resources())) + .then()); + } + // -------------------------- // Prompts // -------------------------- @@ -682,6 +823,13 @@ public class McpAsyncClient { }; // @formatter:on } + private NotificationHandler asyncPromptsChangeNotificationHandler( + List, Mono>> promptsChangeConsumers) { + return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers) + .flatMap(consumer -> consumer.apply(listPromptsResult.prompts())) + .then()); + } + // -------------------------- // Logging // -------------------------- @@ -711,6 +859,28 @@ public class McpAsyncClient { }; } + /** + * 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) { + + return params -> { + McpSchema.LoggingMessageNotification loggingMessageNotification = transport.unmarshalFrom(params, + new TypeReference() { + }); + + return Flux.fromIterable(loggingConsumers) + .flatMap(consumer -> consumer.apply(loggingMessageNotification)) + .then(); + }; + } + /** * 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/org/springframework/ai/mcp/client/McpClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java index cac0001..69a811e 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java @@ -24,6 +24,8 @@ import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; +import reactor.core.publisher.Mono; + import org.springframework.ai.mcp.spec.ClientMcpTransport; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities; @@ -58,24 +60,30 @@ import org.springframework.ai.mcp.util.Assert; * * *

- * Example of creating a basic client:

{@code
- * McpClient.using(transport)
+ * Example of creating a basic synchronous client: 
{@code
+ * McpClient.sync(transport)
  *     .requestTimeout(Duration.ofSeconds(5))
- *     .sync(); // or .async()
+ *     .build();
+ * }
+ * + * Example of creating a basic asynchronous client:
{@code
+ * McpClient.async(transport)
+ *     .requestTimeout(Duration.ofSeconds(5))
+ *     .build();
  * }
* *

- * Example with advanced configuration:

{@code
- * McpClient.using(transport)
+ * Example with advanced asynchronous configuration: 
{@code
+ * McpClient.async(transport)
  *     .requestTimeout(Duration.ofSeconds(10))
  *     .capabilities(new ClientCapabilities(...))
  *     .clientInfo(new Implementation("My Client", "1.0.0"))
  *     .roots(new Root("file://workspace", "Workspace Files"))
- *     .toolsChangeConsumer(tools -> System.out.println("Tools updated: " + tools))
- *     .resourcesChangeConsumer(resources -> System.out.println("Resources updated: " + resources))
- *     .promptsChangeConsumer(prompts -> System.out.println("Prompts updated: " + prompts))
- *     .loggingConsumer(message -> System.out.println("Log message: " + message))
- *     .async();
+ *     .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> System.out.println("Tools updated: " + tools)))
+ *     .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> System.out.println("Resources updated: " + resources)))
+ *     .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> System.out.println("Prompts updated: " + prompts)))
+ *     .loggingConsumer(message -> Mono.fromRunnable(() -> System.out.println("Log message: " + message)))
+ *     .build();
  * }
* *

@@ -115,7 +123,11 @@ public interface McpClient { * and {@code SseClientTransport} for SSE-based communication. * @return A new builder instance for configuring the client * @throws IllegalArgumentException if transport is null + * @deprecated Use {@link #sync(ClientMcpTransport)} or + * {@link #async(ClientMcpTransport)} specification builder to configure the client + * and build an instance. */ + @Deprecated public static Builder using(ClientMcpTransport transport) { return new Builder(transport); } @@ -136,7 +148,12 @@ public interface McpClient { *

  • Change notification handlers for tools, resources, and prompts *
  • Custom message sampling logic * + * + * @deprecated Use {@link #sync(ClientMcpTransport)} or + * {@link #async(ClientMcpTransport)} specification builder to instantiate an + * instance. */ + @Deprecated public static class Builder { private final ClientMcpTransport transport; @@ -353,4 +370,431 @@ public interface McpClient { } + /** + * Synchronous client specification. + */ + class SyncSpec { + + private final ClientMcpTransport transport; + + private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout + + private ClientCapabilities capabilities; + + private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1"); + + private Map roots = new HashMap<>(); + + private List>> toolsChangeConsumers = new ArrayList<>(); + + private List>> resourcesChangeConsumers = new ArrayList<>(); + + private List>> promptsChangeConsumers = new ArrayList<>(); + + private List> loggingConsumers = new ArrayList<>(); + + private Function samplingHandler; + + private SyncSpec(ClientMcpTransport transport) { + this.transport = transport; + } + + /** + * Sets the duration to wait for server responses before timing out requests. This + * timeout applies to all requests made through the client, including tool calls, + * resource access, and prompt operations. + * @param requestTimeout The duration to wait before timing out requests. Must not + * be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if requestTimeout is null + */ + public SyncSpec requestTimeout(Duration requestTimeout) { + Assert.notNull(requestTimeout, "Request timeout must not be null"); + this.requestTimeout = requestTimeout; + return this; + } + + /** + * Sets the client capabilities that will be advertised to the server during + * connection initialization. Capabilities define what features the client + * supports, such as tool execution, resource access, and prompt handling. + * @param capabilities The client capabilities configuration. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if capabilities is null + */ + public SyncSpec capabilities(ClientCapabilities capabilities) { + Assert.notNull(capabilities, "Capabilities must not be null"); + this.capabilities = capabilities; + return this; + } + + /** + * Sets the client implementation information that will be shared with the server + * during connection initialization. This helps with version compatibility and + * debugging. + * @param clientInfo The client implementation details including name and version. + * Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if clientInfo is null + */ + public SyncSpec clientInfo(Implementation clientInfo) { + Assert.notNull(clientInfo, "Client info must not be null"); + this.clientInfo = clientInfo; + return this; + } + + /** + * Sets the root URIs that this client can access. Roots define the base URIs for + * resources that the client can request from the server. For example, a root + * might be "file://workspace" for accessing workspace files. + * @param roots A list of root definitions. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if roots is null + */ + public SyncSpec roots(List roots) { + Assert.notNull(roots, "Roots must not be null"); + for (Root root : roots) { + this.roots.put(root.uri(), root); + } + return this; + } + + /** + * Sets the root URIs that this client can access, using a varargs parameter for + * convenience. This is an alternative to {@link #roots(List)}. + * @param roots An array of root definitions. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if roots is null + * @see #roots(List) + */ + public SyncSpec roots(Root... roots) { + Assert.notNull(roots, "Roots must not be null"); + for (Root root : roots) { + this.roots.put(root.uri(), root); + } + return this; + } + + /** + * Sets a custom sampling handler for processing message creation requests. The + * sampling handler can modify or validate messages before they are sent to the + * server, enabling custom processing logic. + * @param samplingHandler A function that processes message requests and returns + * results. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if samplingHandler is null + */ + public SyncSpec sampling(Function samplingHandler) { + Assert.notNull(samplingHandler, "Sampling handler must not be null"); + this.samplingHandler = samplingHandler; + return this; + } + + /** + * Adds a consumer to be notified when the available tools change. This allows the + * client to react to changes in the server's tool capabilities, such as tools + * being added or removed. + * @param toolsChangeConsumer A consumer that receives the updated list of + * available tools. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if toolsChangeConsumer is null + */ + public SyncSpec toolsChangeConsumer(Consumer> toolsChangeConsumer) { + Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null"); + this.toolsChangeConsumers.add(toolsChangeConsumer); + return this; + } + + /** + * Adds a consumer to be notified when the available resources change. This allows + * the client to react to changes in the server's resource availability, such as + * files being added or removed. + * @param resourcesChangeConsumer A consumer that receives the updated list of + * available resources. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if resourcesChangeConsumer is null + */ + public SyncSpec resourcesChangeConsumer(Consumer> resourcesChangeConsumer) { + Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null"); + this.resourcesChangeConsumers.add(resourcesChangeConsumer); + return this; + } + + /** + * Adds a consumer to be notified when the available prompts change. This allows + * the client to react to changes in the server's prompt templates, such as new + * templates being added or existing ones being modified. + * @param promptsChangeConsumer A consumer that receives the updated list of + * available prompts. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if promptsChangeConsumer is null + */ + public SyncSpec promptsChangeConsumer(Consumer> promptsChangeConsumer) { + Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null"); + this.promptsChangeConsumers.add(promptsChangeConsumer); + return this; + } + + /** + * Adds a consumer to be notified when logging messages are received from the + * server. This allows the client to react to log messages, such as warnings or + * errors, that are sent by the server. + * @param loggingConsumer A consumer that receives logging messages. Must not be + * null. + * @return This builder instance for method chaining + */ + public SyncSpec loggingConsumer(Consumer loggingConsumer) { + Assert.notNull(loggingConsumer, "Logging consumer must not be null"); + this.loggingConsumers.add(loggingConsumer); + return this; + } + + /** + * Adds multiple consumers to be notified when logging messages are received from + * the server. This allows the client to react to log messages, such as warnings + * or errors, that are sent by the server. + * @param loggingConsumers A list of consumers that receive logging messages. Must + * not be null. + * @return This builder instance for method chaining + */ + public SyncSpec loggingConsumers(List> loggingConsumers) { + Assert.notNull(loggingConsumers, "Logging consumers must not be null"); + this.loggingConsumers.addAll(loggingConsumers); + return this; + } + + /** + * Create an instance of {@link McpSyncClient} with the provided configurations or + * sensible defaults. + * @return a new instance of {@link McpSyncClient}. + */ + public McpSyncClient build() { + McpClientFeatures.Sync syncFeatures = new McpClientFeatures.Sync(this.clientInfo, this.capabilities, + this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.promptsChangeConsumers, + this.loggingConsumers, this.samplingHandler); + + McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); + + return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, asyncFeatures)); + } + + } + + /** + * Asynchronous client specification. + */ + class AsyncSpec { + + private final ClientMcpTransport transport; + + private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout + + private ClientCapabilities capabilities; + + private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1"); + + private Map roots = new HashMap<>(); + + private List, Mono>> toolsChangeConsumers = new ArrayList<>(); + + private List, Mono>> resourcesChangeConsumers = new ArrayList<>(); + + private List, Mono>> promptsChangeConsumers = new ArrayList<>(); + + private List>> loggingConsumers = new ArrayList<>(); + + private Function> samplingHandler; + + private AsyncSpec(ClientMcpTransport transport) { + this.transport = transport; + } + + /** + * Sets the duration to wait for server responses before timing out requests. This + * timeout applies to all requests made through the client, including tool calls, + * resource access, and prompt operations. + * @param requestTimeout The duration to wait before timing out requests. Must not + * be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if requestTimeout is null + */ + public AsyncSpec requestTimeout(Duration requestTimeout) { + Assert.notNull(requestTimeout, "Request timeout must not be null"); + this.requestTimeout = requestTimeout; + return this; + } + + /** + * Sets the client capabilities that will be advertised to the server during + * connection initialization. Capabilities define what features the client + * supports, such as tool execution, resource access, and prompt handling. + * @param capabilities The client capabilities configuration. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if capabilities is null + */ + public AsyncSpec capabilities(ClientCapabilities capabilities) { + Assert.notNull(capabilities, "Capabilities must not be null"); + this.capabilities = capabilities; + return this; + } + + /** + * Sets the client implementation information that will be shared with the server + * during connection initialization. This helps with version compatibility and + * debugging. + * @param clientInfo The client implementation details including name and version. + * Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if clientInfo is null + */ + public AsyncSpec clientInfo(Implementation clientInfo) { + Assert.notNull(clientInfo, "Client info must not be null"); + this.clientInfo = clientInfo; + return this; + } + + /** + * Sets the root URIs that this client can access. Roots define the base URIs for + * resources that the client can request from the server. For example, a root + * might be "file://workspace" for accessing workspace files. + * @param roots A list of root definitions. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if roots is null + */ + public AsyncSpec roots(List roots) { + Assert.notNull(roots, "Roots must not be null"); + for (Root root : roots) { + this.roots.put(root.uri(), root); + } + return this; + } + + /** + * Sets the root URIs that this client can access, using a varargs parameter for + * convenience. This is an alternative to {@link #roots(List)}. + * @param roots An array of root definitions. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if roots is null + * @see #roots(List) + */ + public AsyncSpec roots(Root... roots) { + Assert.notNull(roots, "Roots must not be null"); + for (Root root : roots) { + this.roots.put(root.uri(), root); + } + return this; + } + + /** + * Sets a custom sampling handler for processing message creation requests. The + * sampling handler can modify or validate messages before they are sent to the + * server, enabling custom processing logic. + * @param samplingHandler A function that processes message requests and returns + * results. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if samplingHandler is null + */ + public AsyncSpec sampling(Function> samplingHandler) { + Assert.notNull(samplingHandler, "Sampling handler must not be null"); + this.samplingHandler = samplingHandler; + return this; + } + + /** + * Adds a consumer to be notified when the available tools change. This allows the + * client to react to changes in the server's tool capabilities, such as tools + * being added or removed. + * @param toolsChangeConsumer A consumer that receives the updated list of + * available tools. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if toolsChangeConsumer is null + */ + public AsyncSpec toolsChangeConsumer(Function, Mono> toolsChangeConsumer) { + Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null"); + this.toolsChangeConsumers.add(toolsChangeConsumer); + return this; + } + + /** + * Adds a consumer to be notified when the available resources change. This allows + * the client to react to changes in the server's resource availability, such as + * files being added or removed. + * @param resourcesChangeConsumer A consumer that receives the updated list of + * available resources. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if resourcesChangeConsumer is null + */ + public AsyncSpec resourcesChangeConsumer( + Function, Mono> resourcesChangeConsumer) { + Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null"); + this.resourcesChangeConsumers.add(resourcesChangeConsumer); + return this; + } + + /** + * Adds a consumer to be notified when the available prompts change. This allows + * the client to react to changes in the server's prompt templates, such as new + * templates being added or existing ones being modified. + * @param promptsChangeConsumer A consumer that receives the updated list of + * available prompts. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if promptsChangeConsumer is null + */ + public AsyncSpec promptsChangeConsumer(Function, Mono> promptsChangeConsumer) { + Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null"); + this.promptsChangeConsumers.add(promptsChangeConsumer); + return this; + } + + /** + * Adds a consumer to be notified when logging messages are received from the + * server. This allows the client to react to log messages, such as warnings or + * errors, that are sent by the server. + * @param loggingConsumer A consumer that receives logging messages. Must not be + * null. + * @return This builder instance for method chaining + */ + public AsyncSpec loggingConsumer(Function> loggingConsumer) { + Assert.notNull(loggingConsumer, "Logging consumer must not be null"); + this.loggingConsumers.add(loggingConsumer); + return this; + } + + /** + * Adds multiple consumers to be notified when logging messages are received from + * the server. This allows the client to react to log messages, such as warnings + * or errors, that are sent by the server. + * @param loggingConsumers A list of consumers that receive logging messages. Must + * not be null. + * @return This builder instance for method chaining + */ + public AsyncSpec loggingConsumers( + List>> loggingConsumers) { + Assert.notNull(loggingConsumers, "Logging consumers must not be null"); + this.loggingConsumers.addAll(loggingConsumers); + return this; + } + + /** + * Create an instance of {@link McpAsyncClient} with the provided configurations + * or sensible defaults. + * @return a new instance of {@link McpAsyncClient}. + */ + public McpAsyncClient build() { + return new McpAsyncClient(this.transport, this.requestTimeout, + new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots, + this.toolsChangeConsumers, this.resourcesChangeConsumers, this.promptsChangeConsumers, + this.loggingConsumers, this.samplingHandler)); + } + + } + + static SyncSpec sync(ClientMcpTransport transport) { + return new SyncSpec(transport); + } + + static AsyncSpec async(ClientMcpTransport transport) { + return new AsyncSpec(transport); + } + } diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java new file mode 100644 index 0000000..9277398 --- /dev/null +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClientFeatures.java @@ -0,0 +1,200 @@ +package org.springframework.ai.mcp.client; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Function; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.util.Assert; +import org.springframework.ai.mcp.util.Utils; + +/** + * Internal representation of features and capabilities for Model Context Protocol (MCP) + * clients. This class provides two record types for managing client features: + *
      + *
    • {@link Async} for non-blocking operations with Project Reactor's Mono responses + *
    • {@link Sync} for blocking operations with direct responses + *
    + * + *

    + * Each feature specification includes: + *

      + *
    • Client implementation information and capabilities + *
    • Root URI mappings for resource access + *
    • Change notification handlers for tools, resources, and prompts + *
    • Logging message consumers + *
    • Message sampling handlers for request processing + *
    + * + *

    + * The class supports conversion between synchronous and asynchronous specifications + * through the {@link Async#fromSync} method, which ensures proper handling of blocking + * operations in non-blocking contexts by scheduling them on a bounded elastic scheduler. + * + * @see McpClient + * @see McpSchema.Implementation + * @see McpSchema.ClientCapabilities + */ +class McpClientFeatures { + + /** + * Asynchronous client features specification providing the capabilities and request + * and notification handlers. + * + * @param clientInfo the client implementation information. + * @param clientCapabilities the client capabilities. + * @param roots the roots. + * @param toolsChangeConsumers the tools change consumers. + * @param resourcesChangeConsumers the resources change consumers. + * @param promptsChangeConsumers the prompts change consumers. + * @param loggingConsumers the logging consumers. + * @param samplingHandler the sampling handler. + */ + record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, + Map roots, List, Mono>> toolsChangeConsumers, + List, Mono>> resourcesChangeConsumers, + List, Mono>> promptsChangeConsumers, + List>> loggingConsumers, + Function> samplingHandler) { + + /** + * Create an instance and validate the arguments. + * @param clientInfo the client implementation information. + * @param clientCapabilities the client capabilities. + * @param roots the roots. + * @param toolsChangeConsumers the tools change consumers. + * @param resourcesChangeConsumers the resources change consumers. + * @param promptsChangeConsumers the prompts change consumers. + * @param loggingConsumers the logging consumers. + * @param samplingHandler the sampling handler. + */ + public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, + Map roots, + List, Mono>> toolsChangeConsumers, + List, Mono>> resourcesChangeConsumers, + List, Mono>> promptsChangeConsumers, + List>> loggingConsumers, + Function> samplingHandler) { + + Assert.notNull(clientInfo, "Client info must not be null"); + + this.clientInfo = clientInfo; + this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities + : new McpSchema.ClientCapabilities(null, + !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, + samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null); + this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>(); + + this.toolsChangeConsumers = toolsChangeConsumers; + this.resourcesChangeConsumers = resourcesChangeConsumers; + this.promptsChangeConsumers = promptsChangeConsumers; + this.loggingConsumers = loggingConsumers; + this.samplingHandler = samplingHandler; + } + + /** + * Convert a synchronous specification into an asynchronous one and provide + * blocking code offloading to prevent accidental blocking of the non-blocking + * transport. + * @param syncSpec a potentially blocking, synchronous specification. + * @return a specification which is protected from blocking calls specified by the + * user. + */ + public static Async fromSync(Sync syncSpec) { + List, Mono>> toolsChangeConsumers = new ArrayList<>(); + for (Consumer> consumer : syncSpec.toolsChangeConsumers()) { + toolsChangeConsumers.add(t -> Mono.fromRunnable(() -> consumer.accept(t)) + .subscribeOn(Schedulers.boundedElastic())); + } + + List, Mono>> resourcesChangeConsumers = new ArrayList<>(); + for (Consumer> consumer : syncSpec.resourcesChangeConsumers()) { + resourcesChangeConsumers.add(r -> Mono.fromRunnable(() -> consumer.accept(r)) + .subscribeOn(Schedulers.boundedElastic())); + } + + List, Mono>> promptsChangeConsumers = new ArrayList<>(); + + for (Consumer> consumer : syncSpec.promptsChangeConsumers()) { + promptsChangeConsumers.add(p -> Mono.fromRunnable(() -> consumer.accept(p)) + .subscribeOn(Schedulers.boundedElastic())); + } + + List>> loggingConsumers = new ArrayList<>(); + for (Consumer consumer : syncSpec.loggingConsumers()) { + loggingConsumers.add(l -> Mono.fromRunnable(() -> consumer.accept(l)) + .subscribeOn(Schedulers.boundedElastic())); + } + + Function> samplingHandler = r -> Mono + .fromCallable(() -> syncSpec.samplingHandler().apply(r)) + .subscribeOn(Schedulers.boundedElastic()); + return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(), + toolsChangeConsumers, resourcesChangeConsumers, promptsChangeConsumers, loggingConsumers, + samplingHandler); + } + } + + /** + * Synchronous client features specification providing the capabilities and request + * and notification handlers. + * + * @param clientInfo the client implementation information. + * @param clientCapabilities the client capabilities. + * @param roots the roots. + * @param toolsChangeConsumers the tools change consumers. + * @param resourcesChangeConsumers the resources change consumers. + * @param promptsChangeConsumers the prompts change consumers. + * @param loggingConsumers the logging consumers. + * @param samplingHandler the sampling handler. + */ + public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, + Map roots, List>> toolsChangeConsumers, + List>> resourcesChangeConsumers, + List>> promptsChangeConsumers, + List> loggingConsumers, + Function samplingHandler) { + + /** + * Create an instance and validate the arguments. + * @param clientInfo the client implementation information. + * @param clientCapabilities the client capabilities. + * @param roots the roots. + * @param toolsChangeConsumers the tools change consumers. + * @param resourcesChangeConsumers the resources change consumers. + * @param promptsChangeConsumers the prompts change consumers. + * @param loggingConsumers the logging consumers. + * @param samplingHandler the sampling handler. + */ + public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, + Map roots, List>> toolsChangeConsumers, + List>> resourcesChangeConsumers, + List>> promptsChangeConsumers, + List> loggingConsumers, + Function samplingHandler) { + + Assert.notNull(clientInfo, "Client info must not be null"); + + this.clientInfo = clientInfo; + this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities + : new McpSchema.ClientCapabilities(null, + !Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null, + samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null); + this.roots = roots != null ? new HashMap<>(roots) : new HashMap<>(); + + this.toolsChangeConsumers = toolsChangeConsumers; + this.resourcesChangeConsumers = resourcesChangeConsumers; + this.promptsChangeConsumers = promptsChangeConsumers; + this.loggingConsumers = loggingConsumers; + this.samplingHandler = samplingHandler; + } + } + +} diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java index 8cc018a..954f7aa 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java @@ -29,10 +29,39 @@ import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult; import org.springframework.ai.mcp.util.Assert; /** - * A synchronous client for the Model Context Protocol (MCP). + * A synchronous client implementation for the Model Context Protocol (MCP) that wraps an + * {@link McpAsyncClient} to provide blocking operations. + * + *

    + * This client implements the MCP specification by delegating to an asynchronous client + * and blocking on the results. Key features include: + *

      + *
    • Synchronous, blocking API for simpler integration in non-reactive applications + *
    • Tool discovery and invocation for server-provided functionality + *
    • Resource access and management with URI-based addressing + *
    • Prompt template handling for standardized AI interactions + *
    • Real-time notifications for tools, resources, and prompts changes + *
    • Structured logging with configurable severity levels + *
    + * + *

    + * The client follows the same lifecycle as its async counterpart: + *

      + *
    1. Initialization - Establishes connection and negotiates capabilities + *
    2. Normal Operation - Handles requests and notifications + *
    3. Graceful Shutdown - Ensures clean connection termination + *
    + * + *

    + * This implementation implements {@link AutoCloseable} for resource cleanup and provides + * both immediate and graceful shutdown options. All operations block until completion or + * timeout, making it suitable for traditional synchronous programming models. * * @author Dariusz Jędrzejczyk * @author Christian Tzolov + * @see McpClient + * @see McpAsyncClient + * @see McpSchema */ public class McpSyncClient implements AutoCloseable { 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 37d0c65..d5e3043 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 @@ -24,10 +24,12 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; +import java.util.function.Function; import com.fasterxml.jackson.core.type.TypeReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; @@ -48,9 +50,43 @@ import org.springframework.ai.mcp.util.Utils; /** * The Model Context Protocol (MCP) server implementation that provides asynchronous - * communication. + * communication using Project Reactor's Mono and Flux types. + * + *

    + * This server implements the MCP specification, enabling AI models to expose tools, + * resources, and prompts through a standardized interface. Key features include: + *

      + *
    • Asynchronous communication using reactive programming patterns + *
    • Dynamic tool registration and management + *
    • Resource handling with URI-based addressing + *
    • Prompt template management + *
    • Real-time client notifications for state changes + *
    • Structured logging with configurable severity levels + *
    • Support for client-side AI model sampling + *
    + * + *

    + * The server follows a lifecycle: + *

      + *
    1. Initialization - Accepts client connections and negotiates capabilities + *
    2. Normal Operation - Handles client requests and sends notifications + *
    3. Graceful Shutdown - Ensures clean connection termination + *
    + * + *

    + * This implementation uses Project Reactor for non-blocking operations, making it + * suitable for high-throughput scenarios and reactive applications. All operations return + * Mono or Flux types that can be composed into reactive pipelines. + * + *

    + * The server supports runtime modification of its capabilities through methods like + * {@link #addTool}, {@link #addResource}, and {@link #addPrompt}, automatically notifying + * connected clients of changes when configured to do so. * * @author Christian Tzolov + * @see McpServer + * @see McpSchema + * @see DefaultMcpSession */ public class McpAsyncServer { @@ -75,16 +111,84 @@ public class McpAsyncServer { /** * Thread-safe list of tool handlers that can be modified at runtime. */ - private final CopyOnWriteArrayList tools; + private final CopyOnWriteArrayList tools = new CopyOnWriteArrayList<>(); - private final CopyOnWriteArrayList resourceTemplates; + private final CopyOnWriteArrayList resourceTemplates = new CopyOnWriteArrayList<>(); - private final ConcurrentHashMap resources; + private final ConcurrentHashMap resources = new ConcurrentHashMap<>(); - private final ConcurrentHashMap prompts; + private final ConcurrentHashMap prompts = new ConcurrentHashMap<>(); private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG; + /** + * Create a new McpAsyncServer with the given transport and capabilities. + * @param mcpTransport The transport layer implementation for MCP communication. + * @param features The MCP server supported features. + */ + McpAsyncServer(ServerMcpTransport mcpTransport, McpServerFeatures.Async features) { + this.serverInfo = features.serverInfo(); + this.serverCapabilities = features.serverCapabilities(); + this.tools.addAll(features.tools()); + this.resources.putAll(features.resources()); + this.resourceTemplates.addAll(features.resourceTemplates()); + this.prompts.putAll(features.prompts()); + + Map> requestHandlers = new HashMap<>(); + + // Initialize request handlers for standard MCP methods + requestHandlers.put(McpSchema.METHOD_INITIALIZE, initializeRequestHandler()); + + // Ping MUST respond with an empty data, but not NULL response. + requestHandlers.put(McpSchema.METHOD_PING, (params) -> Mono.just("")); + + // Add tools API handlers if the tool capability is enabled + if (this.serverCapabilities.tools() != null) { + requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler()); + requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler()); + } + + // Add resources API handlers if provided + if (!Utils.isEmpty(this.resources)) { + 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)) { + requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler()); + requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler()); + } + + // Add logging API handlers if the logging capability is enabled + if (this.serverCapabilities.logging() != null) { + requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler()); + } + + Map notificationHandlers = new HashMap<>(); + + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (params) -> Mono.empty()); + + List, Mono>> rootsChangeConsumers = features.rootsChangeConsumers(); + + if (Utils.isEmpty(rootsChangeConsumers)) { + rootsChangeConsumers = List.of((roots) -> Mono.fromRunnable(() -> logger + .warn("Roots list changed notification, but no consumers provided. Roots list changed: {}", roots))); + } + + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED, + asyncRootsListChangedNotificationHandler(rootsChangeConsumers)); + + this.transport = mcpTransport; + this.mcpSession = new DefaultMcpSession(Duration.ofSeconds(10), mcpTransport, requestHandlers, + notificationHandlers); + } + /** * Create a new McpAsyncServer with the given transport and capabilities. * @param mcpTransport The transport layer implementation for MCP communication @@ -96,18 +200,28 @@ public class McpAsyncServer { * @param prompts The map of prompt registrations * @param rootsChangeConsumers The list of consumers that will be notified when the * roots list changes + * @deprecated Use {@link McpServer#sync(ServerMcpTransport)} or + * {@link McpServer#async(ServerMcpTransport)} to create a new server instance. */ + @Deprecated public McpAsyncServer(ServerMcpTransport mcpTransport, McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, List resourceTemplates, Map prompts, List>> rootsChangeConsumers) { this.serverInfo = serverInfo; - this.tools = new CopyOnWriteArrayList<>(tools != null ? tools : List.of()); - this.resources = !Utils.isEmpty(resources) ? new ConcurrentHashMap<>(resources) : new ConcurrentHashMap<>(); - this.resourceTemplates = !Utils.isEmpty(resourceTemplates) ? new CopyOnWriteArrayList<>(resourceTemplates) - : new CopyOnWriteArrayList<>(); - this.prompts = !Utils.isEmpty(prompts) ? new ConcurrentHashMap<>(prompts) : new ConcurrentHashMap<>(); + if (!Utils.isEmpty(tools)) { + this.tools.addAll(McpServer.mapDeprecatedTools(tools)); + } + if (!Utils.isEmpty(resources)) { + this.resources.putAll(McpServer.mapDeprecatedResources(resources)); + } + if (!Utils.isEmpty(resourceTemplates)) { + this.resourceTemplates.addAll(resourceTemplates); + } + if (!Utils.isEmpty(prompts)) { + this.prompts.putAll(McpServer.mapDeprecatedPrompts(prompts)); + } this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities : new McpSchema.ServerCapabilities( null, // experimental @@ -118,13 +232,13 @@ public class McpAsyncServer { : null, !Utils.isEmpty(this.tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null); - Map requestHandlers = new HashMap<>(); + Map> requestHandlers = new HashMap<>(); // Initialize request handlers for standard MCP methods requestHandlers.put(McpSchema.METHOD_INITIALIZE, initializeRequestHandler()); // Ping MUST respond with an empty data, but not NULL response. - requestHandlers.put(McpSchema.METHOD_PING, (params) -> Mono.just("")); + requestHandlers.put(McpSchema.METHOD_PING, (params) -> Mono.just("")); // Add tools API handlers if the tool capability is enabled if (this.serverCapabilities.tools() != null) { @@ -173,7 +287,7 @@ public class McpAsyncServer { // --------------------------------------- // Lifecycle Management // --------------------------------------- - private DefaultMcpSession.RequestHandler initializeRequestHandler() { + private DefaultMcpSession.RequestHandler initializeRequestHandler() { return params -> { McpSchema.InitializeRequest initializeRequest = transport.unmarshalFrom(params, new TypeReference() { @@ -187,14 +301,13 @@ public class McpAsyncServer { initializeRequest.clientInfo()); if (!McpSchema.LATEST_PROTOCOL_VERSION.equals(initializeRequest.protocolVersion())) { - return Mono - .error(new McpError( - "Unsupported protocol version from client: " + initializeRequest.protocolVersion())) + return Mono.error(new McpError( + "Unsupported protocol version from client: " + initializeRequest.protocolVersion())) .publishOn(Schedulers.boundedElastic()); } return Mono - .just(new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, this.serverCapabilities, + .just(new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, this.serverCapabilities, this.serverInfo, null)) .publishOn(Schedulers.boundedElastic()); }; @@ -268,6 +381,7 @@ public class McpAsyncServer { LIST_ROOTS_RESULT_TYPE_REF); } + @Deprecated private NotificationHandler rootsListChnagedNotificationHandler( List>> rootsChangeConsumers) { @@ -281,6 +395,13 @@ public class McpAsyncServer { }; } + private NotificationHandler asyncRootsListChangedNotificationHandler( + List, Mono>> rootsChangeConsumers) { + return params -> listRoots().flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers) + .flatMap(consumer -> consumer.apply(listRootsResult.roots())) + .then()); + } + // --------------------------------------- // Tool Management // --------------------------------------- @@ -290,6 +411,44 @@ public class McpAsyncServer { * @param toolRegistration The tool registration to add * @return Mono that completes when clients have been notified of the change */ + public Mono addTool(McpServerFeatures.AsyncToolRegistration toolRegistration) { + if (toolRegistration == null) { + return Mono.error(new McpError("Tool registration must not be null")); + } + if (toolRegistration.tool() == null) { + return Mono.error(new McpError("Tool must not be null")); + } + if (toolRegistration.call() == null) { + return Mono.error(new McpError("Tool call handler must not be null")); + } + if (this.serverCapabilities.tools() == null) { + return Mono.error(new McpError("Server must be configured with tool capabilities")); + } + + return Mono.defer(() -> { + // Check for duplicate tool names + if (this.tools.stream().anyMatch(th -> th.tool().name().equals(toolRegistration.tool().name()))) { + return Mono + .error(new McpError("Tool with name '" + toolRegistration.tool().name() + "' already exists")); + } + + this.tools.add(toolRegistration); + logger.info("Added tool handler: {}", toolRegistration.tool().name()); + + if (this.serverCapabilities.tools().listChanged()) { + return notifyToolsListChanged(); + } + return Mono.empty(); + }); + } + + /** + * Add a new tool registration at runtime. + * @param toolRegistration The tool registration to add + * @return Mono that completes when clients have been notified of the change + * @deprecated Use {@link #addTool(McpServerFeatures.AsyncToolRegistration)}. + */ + @Deprecated public Mono addTool(ToolRegistration toolRegistration) { if (toolRegistration == null) { return Mono.error(new McpError("Tool registration must not be null")); @@ -309,7 +468,7 @@ public class McpAsyncServer { return Mono.error(new McpError("Tool with name '" + toolRegistration.tool().name() + "' already exists")); } - this.tools.add(toolRegistration); + this.tools.add(McpServer.mapDeprecatedTool(toolRegistration)); logger.info("Added tool handler: {}", toolRegistration.tool().name()); if (this.serverCapabilities.tools().listChanged()) { return notifyToolsListChanged(); @@ -330,15 +489,17 @@ public class McpAsyncServer { return Mono.error(new McpError("Server must be configured with tool capabilities")); } - boolean removed = this.tools.removeIf(toolRegistration -> toolRegistration.tool().name().equals(toolName)); - if (removed) { - logger.info("Removed tool handler: {}", toolName); - if (this.serverCapabilities.tools().listChanged()) { - return notifyToolsListChanged(); + return Mono.defer(() -> { + boolean removed = this.tools.removeIf(toolRegistration -> toolRegistration.tool().name().equals(toolName)); + if (removed) { + logger.info("Removed tool handler: {}", toolName); + if (this.serverCapabilities.tools().listChanged()) { + return notifyToolsListChanged(); + } + return Mono.empty(); } - return Mono.empty(); - } - return Mono.error(new McpError("Tool with name '" + toolName + "' not found")); + return Mono.error(new McpError("Tool with name '" + toolName + "' not found")); + }); } /** @@ -349,32 +510,30 @@ public class McpAsyncServer { return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null); } - private DefaultMcpSession.RequestHandler toolsListRequestHandler() { + private DefaultMcpSession.RequestHandler toolsListRequestHandler() { return params -> { - - List tools = this.tools.stream().map(ToolRegistration::tool).toList(); + List tools = this.tools.stream().map(McpServerFeatures.AsyncToolRegistration::tool).toList(); return Mono.just(new McpSchema.ListToolsResult(tools, null)); }; } - private DefaultMcpSession.RequestHandler toolsCallRequestHandler() { + private DefaultMcpSession.RequestHandler toolsCallRequestHandler() { return params -> { McpSchema.CallToolRequest callToolRequest = transport.unmarshalFrom(params, new TypeReference() { }); - Optional toolRegistration = this.tools.stream() + Optional toolRegistration = this.tools.stream() .filter(tr -> callToolRequest.name().equals(tr.tool().name())) .findAny(); if (toolRegistration.isEmpty()) { - return Mono.error(new McpError("Tool not found: " + callToolRequest.name())); + return Mono.error(new McpError("Tool not found: " + callToolRequest.name())); } - return Mono.fromCallable(() -> toolRegistration.get().call().apply(callToolRequest.arguments())) - .map(result -> (Object) result) - .subscribeOn(Schedulers.boundedElastic()); + return toolRegistration.map(tool -> tool.call().apply(callToolRequest.arguments())) + .orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name()))); }; } @@ -387,6 +546,35 @@ public class McpAsyncServer { * @param resourceHandler The resource handler to add * @return Mono that completes when clients have been notified of the change */ + public Mono addResource(McpServerFeatures.AsyncResourceRegistration resourceHandler) { + if (resourceHandler == null || resourceHandler.resource() == null) { + return Mono.error(new McpError("Resource must not be null")); + } + + if (this.serverCapabilities.resources() == null) { + return Mono.error(new McpError("Server must be configured with resource capabilities")); + } + + return Mono.defer(() -> { + if (this.resources.putIfAbsent(resourceHandler.resource().uri(), resourceHandler) != null) { + return Mono + .error(new McpError("Resource with URI '" + resourceHandler.resource().uri() + "' already exists")); + } + logger.info("Added resource handler: {}", resourceHandler.resource().uri()); + if (this.serverCapabilities.resources().listChanged()) { + return notifyResourcesListChanged(); + } + return Mono.empty(); + }); + } + + /** + * Add a new resource handler at runtime. + * @param resourceHandler The resource handler to add + * @return Mono that completes when clients have been notified of the change + * @deprecated Use {@link #addResource(McpServerFeatures.AsyncResourceRegistration)}. + */ + @Deprecated public Mono addResource(ResourceRegistration resourceHandler) { if (resourceHandler == null || resourceHandler.resource() == null) { return Mono.error(new McpError("Resource must not be null")); @@ -401,7 +589,7 @@ public class McpAsyncServer { .error(new McpError("Resource with URI '" + resourceHandler.resource().uri() + "' already exists")); } - this.resources.put(resourceHandler.resource().uri(), resourceHandler); + this.resources.put(resourceHandler.resource().uri(), McpServer.mapDeprecatedResource(resourceHandler)); logger.info("Added resource handler: {}", resourceHandler.resource().uri()); if (this.serverCapabilities.resources().listChanged()) { return notifyResourcesListChanged(); @@ -422,15 +610,17 @@ public class McpAsyncServer { return Mono.error(new McpError("Server must be configured with resource capabilities")); } - ResourceRegistration removed = this.resources.remove(resourceUri); - if (removed != null) { - logger.info("Removed resource handler: {}", resourceUri); - if (this.serverCapabilities.resources().listChanged()) { - return notifyResourcesListChanged(); + return Mono.defer(() -> { + McpServerFeatures.AsyncResourceRegistration removed = this.resources.remove(resourceUri); + if (removed != null) { + logger.info("Removed resource handler: {}", resourceUri); + if (this.serverCapabilities.resources().listChanged()) { + return notifyResourcesListChanged(); + } + return Mono.empty(); } - return Mono.empty(); - } - return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found")); + return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found")); + }); } /** @@ -441,28 +631,30 @@ public class McpAsyncServer { return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null); } - private DefaultMcpSession.RequestHandler resourcesListRequestHandler() { + private DefaultMcpSession.RequestHandler resourcesListRequestHandler() { return params -> { - var resourceList = this.resources.values().stream().map(ResourceRegistration::resource).toList(); + var resourceList = this.resources.values() + .stream() + .map(McpServerFeatures.AsyncResourceRegistration::resource) + .toList(); return Mono.just(new McpSchema.ListResourcesResult(resourceList, null)); }; } - private DefaultMcpSession.RequestHandler resourceTemplateListRequestHandler() { + private DefaultMcpSession.RequestHandler resourceTemplateListRequestHandler() { return params -> Mono.just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null)); } - private DefaultMcpSession.RequestHandler resourcesReadRequestHandler() { + private DefaultMcpSession.RequestHandler resourcesReadRequestHandler() { return params -> { McpSchema.ReadResourceRequest resourceRequest = transport.unmarshalFrom(params, new TypeReference() { }); var resourceUri = resourceRequest.uri(); - if (this.resources.containsKey(resourceUri)) { - return Mono.fromCallable(() -> this.resources.get(resourceUri).readHandler().apply(resourceRequest)) - .map(result -> (Object) result) - .subscribeOn(Schedulers.boundedElastic()); + McpServerFeatures.AsyncResourceRegistration registration = this.resources.get(resourceUri); + if (registration != null) { + return this.resources.get(resourceUri).readHandler().apply(resourceRequest); } return Mono.error(new McpError("Resource not found: " + resourceUri)); }; @@ -477,6 +669,41 @@ public class McpAsyncServer { * @param promptRegistration The prompt handler to add * @return Mono that completes when clients have been notified of the change */ + public Mono addPrompt(McpServerFeatures.AsyncPromptRegistration promptRegistration) { + if (promptRegistration == null) { + return Mono.error(new McpError("Prompt registration must not be null")); + } + if (this.serverCapabilities.prompts() == null) { + return Mono.error(new McpError("Server must be configured with prompt capabilities")); + } + + return Mono.defer(() -> { + McpServerFeatures.AsyncPromptRegistration registration = this.prompts + .putIfAbsent(promptRegistration.prompt().name(), promptRegistration); + if (registration != null) { + return Mono.error( + new McpError("Prompt with name '" + promptRegistration.prompt().name() + "' already exists")); + } + + logger.info("Added prompt handler: {}", promptRegistration.prompt().name()); + + // Servers that declared the listChanged capability SHOULD send a + // notification, + // when the list of available prompts changes + if (this.serverCapabilities.prompts().listChanged()) { + return notifyPromptsListChanged(); + } + return Mono.empty(); + }); + } + + /** + * Add a new prompt handler at runtime. + * @param promptRegistration The prompt handler to add + * @return Mono that completes when clients have been notified of the change + * @deprecated Use {@link #addPrompt(McpServerFeatures.AsyncPromptRegistration)}. + */ + @Deprecated public Mono addPrompt(PromptRegistration promptRegistration) { if (promptRegistration == null) { return Mono.error(new McpError("Prompt registration must not be null")); @@ -490,7 +717,7 @@ public class McpAsyncServer { .error(new McpError("Prompt with name '" + promptRegistration.prompt().name() + "' already exists")); } - this.prompts.put(promptRegistration.prompt().name(), promptRegistration); + this.prompts.put(promptRegistration.prompt().name(), McpServer.mapDeprecatedPrompt(promptRegistration)); logger.info("Added prompt handler: {}", promptRegistration.prompt().name()); @@ -515,18 +742,20 @@ public class McpAsyncServer { return Mono.error(new McpError("Server must be configured with prompt capabilities")); } - PromptRegistration removed = this.prompts.remove(promptName); + return Mono.defer(() -> { + McpServerFeatures.AsyncPromptRegistration removed = this.prompts.remove(promptName); - if (removed != null) { - logger.info("Removed prompt handler: {}", promptName); - // Servers that declared the listChanged capability SHOULD send a - // notification, when the list of available prompts changes - if (this.serverCapabilities.prompts().listChanged()) { - return this.notifyPromptsListChanged(); + if (removed != null) { + logger.info("Removed prompt handler: {}", promptName); + // Servers that declared the listChanged capability SHOULD send a + // notification, when the list of available prompts changes + if (this.serverCapabilities.prompts().listChanged()) { + return this.notifyPromptsListChanged(); + } + return Mono.empty(); } - return Mono.empty(); - } - return Mono.error(new McpError("Prompt with name '" + promptName + "' not found")); + return Mono.error(new McpError("Prompt with name '" + promptName + "' not found")); + }); } /** @@ -537,34 +766,35 @@ public class McpAsyncServer { return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null); } - private DefaultMcpSession.RequestHandler promptsListRequestHandler() { + private DefaultMcpSession.RequestHandler promptsListRequestHandler() { return params -> { // TODO: Implement pagination // McpSchema.PaginatedRequest request = transport.unmarshalFrom(params, // new TypeReference() { // }); - var promptList = this.prompts.values().stream().map(PromptRegistration::prompt).toList(); + var promptList = this.prompts.values() + .stream() + .map(McpServerFeatures.AsyncPromptRegistration::prompt) + .toList(); return Mono.just(new McpSchema.ListPromptsResult(promptList, null)); }; } - private DefaultMcpSession.RequestHandler promptsGetRequestHandler() { + private DefaultMcpSession.RequestHandler promptsGetRequestHandler() { return params -> { McpSchema.GetPromptRequest promptRequest = transport.unmarshalFrom(params, new TypeReference() { }); // Implement prompt retrieval logic here - if (this.prompts.containsKey(promptRequest.name())) { - return Mono - .fromCallable(() -> this.prompts.get(promptRequest.name()).promptHandler().apply(promptRequest)) - .map(result -> (Object) result) - .subscribeOn(Schedulers.boundedElastic()); + McpServerFeatures.AsyncPromptRegistration registration = this.prompts.get(promptRequest.name()); + if (registration == null) { + return Mono.error(new McpError("Prompt not found: " + promptRequest.name())); } - return Mono.error(new McpError("Prompt not found: " + promptRequest.name())); + return registration.promptHandler().apply(promptRequest); }; } @@ -600,13 +830,10 @@ public class McpAsyncServer { * not be sent. * @return A handler that processes logging level change requests */ - private DefaultMcpSession.RequestHandler setLoggerRequestHandler() { + private DefaultMcpSession.RequestHandler setLoggerRequestHandler() { return params -> { - McpSchema.LoggingLevel setLoggerRequest = transport.unmarshalFrom(params, - new TypeReference() { - }); - - this.minLoggingLevel = setLoggerRequest; + this.minLoggingLevel = transport.unmarshalFrom(params, new TypeReference() { + }); return Mono.empty(); }; @@ -615,7 +842,7 @@ public class McpAsyncServer { // --------------------------------------- // Sampling // --------------------------------------- - private static TypeReference CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() { + private static final TypeReference CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() { }; /** diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java index e5fec07..1fbf47b 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java @@ -22,11 +22,14 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Collectors; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; import org.springframework.ai.mcp.spec.McpSchema.ResourceTemplate; -import org.springframework.ai.mcp.spec.McpSchema.Tool; import org.springframework.ai.mcp.spec.McpTransport; import org.springframework.ai.mcp.spec.ServerMcpTransport; import org.springframework.ai.mcp.util.Assert; @@ -47,6 +50,18 @@ import org.springframework.ai.mcp.util.Assert; * * *

    + * Thread Safety: Both synchronous and asynchronous server implementations are + * thread-safe. The synchronous server processes requests sequentially, while the + * asynchronous server can handle concurrent requests safely through its reactive + * programming model. + * + *

    + * Error Handling: The server implementations provide robust error handling through the + * McpError class. Errors are properly propagated to clients while maintaining the + * server's stability. Server implementations should use appropriate error codes and + * provide meaningful error messages to help diagnose issues. + * + *

    * The class provides factory methods to create either: *

      *
    • {@link McpAsyncServer} for non-blocking operations with CompletableFuture responses @@ -54,28 +69,40 @@ import org.springframework.ai.mcp.util.Assert; *
    * *

    - * Example of creating a basic server:

    {@code
    - * McpServer.using(transport)
    - *     .info("my-server", "1.0.0")
    + * Example of creating a basic synchronous server: 
    {@code
    + * McpServer.sync(transport)
    + *     .serverInfo("my-server", "1.0.0")
      *     .tool(new Tool("calculator", "Performs calculations", schema),
      *           args -> new CallToolResult("Result: " + calculate(args)))
    - *     .sync(); // or .async()
    + *     .build();
    + * }
    + * + * Example of creating a basic asynchronous server:
    {@code
    + * McpServer.async(transport)
    + *     .serverInfo("my-server", "1.0.0")
    + *     .tool(new Tool("calculator", "Performs calculations", schema),
    + *           args -> Mono.just(new CallToolResult("Result: " + calculate(args))))
    + *     .build();
      * }
    * *

    - * Example with comprehensive configuration:

    {@code
    - * McpServer.using(transport)
    - *     .info("advanced-server", "2.0.0")
    + * Example with comprehensive asynchronous configuration: 
    {@code
    + * McpServer.async(transport)
    + *     .serverInfo("advanced-server", "2.0.0")
      *     .capabilities(new ServerCapabilities(...))
      *     // Register tools
      *     .tools(
    - *         new ToolRegistration(calculatorTool, calculatorHandler),
    - *         new ToolRegistration(weatherTool, weatherHandler)
    + *         new McpServerFeatures.AsyncToolRegistration(calculatorTool,
    + *             args -> Mono.just(new CallToolResult("Result: " + calculate(args)))),
    + *         new McpServerFeatures.AsyncToolRegistration(weatherTool,
    + *             args -> Mono.just(new CallToolResult("Weather: " + getWeather(args))))
      *     )
      *     // Register resources
      *     .resources(
    - *         new ResourceRegistration(fileResource, fileHandler),
    - *         new ResourceRegistration(dbResource, dbHandler)
    + *         new McpServerFeatures.AsyncResourceRegistration(fileResource,
    + *             req -> Mono.just(new ReadResourceResult(readFile(req)))),
    + *         new McpServerFeatures.AsyncResourceRegistration(dbResource,
    + *             req -> Mono.just(new ReadResourceResult(queryDb(req))))
      *     )
      *     // Add resource templates
      *     .resourceTemplates(
    @@ -84,10 +111,12 @@ import org.springframework.ai.mcp.util.Assert;
      *     )
      *     // Register prompts
      *     .prompts(
    - *         new PromptRegistration(analysisPrompt, analysisHandler),
    - *         new PromptRegistration(summaryPrompt, summaryHandler)
    + *         new McpServerFeatures.AsyncPromptRegistration(analysisPrompt,
    + *             req -> Mono.just(new GetPromptResult(generateAnalysisPrompt(req)))),
    + *         new McpServerFeatures.AsyncPromptRegistration(summaryPrompt,
    + *             req -> Mono.just(new GetPromptResult(generateSummaryPrompt(req))))
      *     )
    - *     .async();
    + *     .build();
      * }
    * * @author Christian Tzolov @@ -97,115 +126,804 @@ import org.springframework.ai.mcp.util.Assert; */ public interface McpServer { - /** - * Registration of a tool with its handler function. Tools are the primary way for MCP - * servers to expose functionality to AI models. Each tool represents a specific - * capability, such as: - *
      - *
    • Performing calculations - *
    • Accessing external APIs - *
    • Querying databases - *
    • Manipulating files - *
    • Executing system commands - *
    - * - *

    - * Example tool registration:

    {@code
    -	 * new ToolRegistration(
    -	 *     new Tool(
    -	 *         "calculator",
    -	 *         "Performs mathematical calculations",
    -	 *         new JsonSchemaObject()
    -	 *             .required("expression")
    -	 *             .property("expression", JsonSchemaType.STRING)
    -	 *     ),
    -	 *     args -> {
    -	 *         String expr = (String) args.get("expression");
    -	 *         return new CallToolResult("Result: " + evaluate(expr));
    -	 *     }
    -	 * )
    -	 * }
    - * - * @param tool The tool definition including name, description, and parameter schema - * @param call The function that implements the tool's logic, receiving arguments and - * returning results - */ - public static record ToolRegistration(Tool tool, Function, CallToolResult> call) { - } - - /** - * Registration of a resource with its handler function. Resources provide context to - * AI models by exposing data such as: - *
      - *
    • File contents - *
    • Database records - *
    • API responses - *
    • System information - *
    • Application state - *
    - * - *

    - * Example resource registration:

    {@code
    -	 * new ResourceRegistration(
    -	 *     new Resource("docs", "Documentation files", "text/markdown"),
    -	 *     request -> {
    -	 *         String content = readFile(request.getPath());
    -	 *         return new ReadResourceResult(content);
    -	 *     }
    -	 * )
    -	 * }
    - * - * @param resource The resource definition including name, description, and MIME type - * @param readHandler The function that handles resource read requests - */ - public static record ResourceRegistration(McpSchema.Resource resource, - Function readHandler) { - } - - /** - * Registration of a prompt template with its handler function. Prompts provide - * structured templates for AI model interactions, supporting: - *
      - *
    • Consistent message formatting - *
    • Parameter substitution - *
    • Context injection - *
    • Response formatting - *
    • Instruction templating - *
    - * - *

    - * Example prompt registration:

    {@code
    -	 * new PromptRegistration(
    -	 *     new Prompt("analyze", "Code analysis template"),
    -	 *     request -> {
    -	 *         String code = request.getArguments().get("code");
    -	 *         return new GetPromptResult(
    -	 *             "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
    -	 *         );
    -	 *     }
    -	 * )
    -	 * }
    - * - * @param prompt The prompt definition including name and description - * @param promptHandler The function that processes prompt requests and returns - * formatted templates - */ - public static record PromptRegistration(McpSchema.Prompt prompt, - Function promptHandler) { - } - /** * Start building an MCP server with the specified transport. * @param transport The transport layer implementation for MCP communication * @return A new builder instance + * @deprecated Use {@link #sync(ServerMcpTransport)} or + * {@link #async(ServerMcpTransport)} to create a server instance. */ + @Deprecated public static Builder using(ServerMcpTransport transport) { return new Builder(transport); } /** - * Builder class for creating MCP servers with custom configuration. + * Asynchronous server specification. */ + class AsyncSpec { + + private static final McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server", + "1.0.0"); + + private final ServerMcpTransport transport; + + private McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO; + + private McpSchema.ServerCapabilities serverCapabilities; + + /** + * The Model Context Protocol (MCP) allows servers to expose tools that can be + * invoked by language models. Tools enable models to interact with external + * systems, such as querying databases, calling APIs, or performing computations. + * Each tool is uniquely identified by a name and includes metadata describing its + * schema. + */ + private final List tools = new ArrayList<>(); + + /** + * The Model Context Protocol (MCP) provides a standardized way for servers to + * expose resources to clients. Resources allow servers to share data that + * provides context to language models, such as files, database schemas, or + * application-specific information. Each resource is uniquely identified by a + * URI. + */ + private final Map resources = new HashMap<>(); + + private List resourceTemplates = new ArrayList<>(); + + /** + * The Model Context Protocol (MCP) provides a standardized way for servers to + * expose prompt templates to clients. Prompts allow servers to provide structured + * messages and instructions for interacting with language models. Clients can + * discover available prompts, retrieve their contents, and provide arguments to + * customize them. + */ + private final Map prompts = new HashMap<>(); + + private final List, Mono>> rootsChangeConsumers = new ArrayList<>(); + + private AsyncSpec(ServerMcpTransport transport) { + Assert.notNull(transport, "Transport must not be null"); + this.transport = transport; + } + + /** + * Sets the server implementation information that will be shared with clients + * during connection initialization. This helps with version compatibility, + * debugging, and server identification. + * @param serverInfo The server implementation details including name and version. + * Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if serverInfo is null + */ + public AsyncSpec serverInfo(McpSchema.Implementation serverInfo) { + Assert.notNull(serverInfo, "Server info must not be null"); + this.serverInfo = serverInfo; + return this; + } + + /** + * Sets the server implementation information using name and version strings. This + * is a convenience method alternative to + * {@link #serverInfo(McpSchema.Implementation)}. + * @param name The server name. Must not be null or empty. + * @param version The server version. Must not be null or empty. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if name or version is null or empty + * @see #serverInfo(McpSchema.Implementation) + */ + public AsyncSpec serverInfo(String name, String version) { + Assert.hasText(name, "Name must not be null or empty"); + Assert.hasText(version, "Version must not be null or empty"); + this.serverInfo = new McpSchema.Implementation(name, version); + return this; + } + + /** + * Sets the server capabilities that will be advertised to clients during + * connection initialization. Capabilities define what features the server + * supports, such as: + *
      + *
    • Tool execution + *
    • Resource access + *
    • Prompt handling + *
    • Streaming responses + *
    • Batch operations + *
    + * @param serverCapabilities The server capabilities configuration. Must not be + * null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if serverCapabilities is null + */ + public AsyncSpec capabilities(McpSchema.ServerCapabilities serverCapabilities) { + this.serverCapabilities = serverCapabilities; + return this; + } + + /** + * Adds a single tool with its implementation handler to the server. This is a + * convenience method for registering individual tools without creating a + * {@link McpServerFeatures.AsyncToolRegistration} explicitly. + * + *

    + * Example usage:

    {@code
    +		 * .tool(
    +		 *     new Tool("calculator", "Performs calculations", schema),
    +		 *     args -> Mono.just(new CallToolResult("Result: " + calculate(args)))
    +		 * )
    +		 * }
    + * @param tool The tool definition including name, description, and schema. Must + * not be null. + * @param handler The function that implements the tool's logic. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if tool or handler is null + */ + public AsyncSpec tool(McpSchema.Tool tool, Function, Mono> handler) { + Assert.notNull(tool, "Tool must not be null"); + Assert.notNull(handler, "Handler must not be null"); + + this.tools.add(new McpServerFeatures.AsyncToolRegistration(tool, handler)); + + return this; + } + + /** + * Adds multiple tools with their handlers to the server using a List. This method + * is useful when tools are dynamically generated or loaded from a configuration + * source. + * @param toolRegistrations The list of tool registrations to add. Must not be + * null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if toolRegistrations is null + * @see #tools(McpServerFeatures.AsyncToolRegistration...) + */ + public AsyncSpec tools(List toolRegistrations) { + Assert.notNull(toolRegistrations, "Tool handlers list must not be null"); + this.tools.addAll(toolRegistrations); + return this; + } + + /** + * Adds multiple tools with their handlers to the server using varargs. This + * method provides a convenient way to register multiple tools inline. + * + *

    + * Example usage:

    {@code
    +		 * .tools(
    +		 *     new McpServerFeatures.AsyncToolRegistration(calculatorTool, calculatorHandler),
    +		 *     new McpServerFeatures.AsyncToolRegistration(weatherTool, weatherHandler),
    +		 *     new McpServerFeatures.AsyncToolRegistration(fileManagerTool, fileManagerHandler)
    +		 * )
    +		 * }
    + * @param toolRegistrations The tool registrations to add. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if toolRegistrations is null + * @see #tools(List) + */ + public AsyncSpec tools(McpServerFeatures.AsyncToolRegistration... toolRegistrations) { + for (McpServerFeatures.AsyncToolRegistration tool : toolRegistrations) { + this.tools.add(tool); + } + return this; + } + + /** + * Registers multiple resources with their handlers using a Map. This method is + * useful when resources are dynamically generated or loaded from a configuration + * source. + * @param resourceRegsitrations Map of resource name to registration. Must not be + * null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if resourceRegsitrations is null + * @see #resources(McpServerFeatures.AsyncResourceRegistration...) + */ + public AsyncSpec resources(Map resourceRegsitrations) { + Assert.notNull(resourceRegsitrations, "Resource handlers map must not be null"); + this.resources.putAll(resourceRegsitrations); + return this; + } + + /** + * Registers multiple resources with their handlers using a List. This method is + * useful when resources need to be added in bulk from a collection. + * @param resourceRegsitrations List of resource registrations. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if resourceRegsitrations is null + * @see #resources(McpServerFeatures.AsyncResourceRegistration...) + */ + public AsyncSpec resources(List resourceRegsitrations) { + Assert.notNull(resourceRegsitrations, "Resource handlers list must not be null"); + for (McpServerFeatures.AsyncResourceRegistration resource : resourceRegsitrations) { + this.resources.put(resource.resource().uri(), resource); + } + return this; + } + + /** + * Registers multiple resources with their handlers using varargs. This method + * provides a convenient way to register multiple resources inline. + * + *

    + * Example usage:

    {@code
    +		 * .resources(
    +		 *     new McpServerFeatures.AsyncResourceRegistration(fileResource, fileHandler),
    +		 *     new McpServerFeatures.AsyncResourceRegistration(dbResource, dbHandler),
    +		 *     new McpServerFeatures.AsyncResourceRegistration(apiResource, apiHandler)
    +		 * )
    +		 * }
    + * @param resourceRegistrations The resource registrations to add. Must not be + * null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if resourceRegistrations is null + */ + public AsyncSpec resources(McpServerFeatures.AsyncResourceRegistration... resourceRegistrations) { + Assert.notNull(resourceRegistrations, "Resource handlers list must not be null"); + for (McpServerFeatures.AsyncResourceRegistration resource : resourceRegistrations) { + this.resources.put(resource.resource().uri(), resource); + } + return this; + } + + /** + * Sets the resource templates that define patterns for dynamic resource access. + * Templates use URI patterns with placeholders that can be filled at runtime. + * + *

    + * Example usage:

    {@code
    +		 * .resourceTemplates(
    +		 *     new ResourceTemplate("file://{path}", "Access files by path"),
    +		 *     new ResourceTemplate("db://{table}/{id}", "Access database records")
    +		 * )
    +		 * }
    + * @param resourceTemplates List of resource templates. If null, clears existing + * templates. + * @return This builder instance for method chaining + * @see #resourceTemplates(ResourceTemplate...) + */ + public AsyncSpec resourceTemplates(List resourceTemplates) { + this.resourceTemplates.addAll(resourceTemplates); + return this; + } + + /** + * Sets the resource templates using varargs for convenience. This is an + * alternative to {@link #resourceTemplates(List)}. + * @param resourceTemplates The resource templates to set. + * @return This builder instance for method chaining + * @see #resourceTemplates(List) + */ + public AsyncSpec resourceTemplates(ResourceTemplate... resourceTemplates) { + for (ResourceTemplate resourceTemplate : resourceTemplates) { + this.resourceTemplates.add(resourceTemplate); + } + return this; + } + + /** + * Registers multiple prompts with their handlers using a Map. This method is + * useful when prompts are dynamically generated or loaded from a configuration + * source. + * + *

    + * Example usage:

    {@code
    +		 * .prompts(Map.of("analysis", new McpServerFeatures.AsyncPromptRegistration(
    +		 *     new Prompt("analysis", "Code analysis template"),
    +		 *     request -> Mono.just(new GetPromptResult(generateAnalysisPrompt(request)))
    +		 * )));
    +		 * }
    + * @param prompts Map of prompt name to registration. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if prompts is null + */ + public AsyncSpec prompts(Map prompts) { + this.prompts.putAll(prompts); + return this; + } + + /** + * Registers multiple prompts with their handlers using a List. This method is + * useful when prompts need to be added in bulk from a collection. + * @param prompts List of prompt registrations. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if prompts is null + * @see #prompts(McpServerFeatures.AsyncPromptRegistration...) + */ + public AsyncSpec prompts(List prompts) { + for (McpServerFeatures.AsyncPromptRegistration prompt : prompts) { + this.prompts.put(prompt.prompt().name(), prompt); + } + return this; + } + + /** + * Registers multiple prompts with their handlers using varargs. This method + * provides a convenient way to register multiple prompts inline. + * + *

    + * Example usage:

    {@code
    +		 * .prompts(
    +		 *     new McpServerFeatures.AsyncPromptRegistration(analysisPrompt, analysisHandler),
    +		 *     new McpServerFeatures.AsyncPromptRegistration(summaryPrompt, summaryHandler),
    +		 *     new McpServerFeatures.AsyncPromptRegistration(reviewPrompt, reviewHandler)
    +		 * )
    +		 * }
    + * @param prompts The prompt registrations to add. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if prompts is null + */ + public AsyncSpec prompts(McpServerFeatures.AsyncPromptRegistration... prompts) { + for (McpServerFeatures.AsyncPromptRegistration prompt : prompts) { + this.prompts.put(prompt.prompt().name(), prompt); + } + return this; + } + + /** + * Registers a consumer that will be notified when the list of roots changes. This + * is useful for updating resource availability dynamically, such as when new + * files are added or removed. + * @param consumer The consumer to register. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if consumer is null + */ + public AsyncSpec rootsChangeConsumer(Function, Mono> consumer) { + Assert.notNull(consumer, "Consumer must not be null"); + this.rootsChangeConsumers.add(consumer); + return this; + } + + /** + * Registers multiple consumers that will be notified when the list of roots + * changes. This method is useful when multiple consumers need to be registered at + * once. + * @param consumers The list of consumers to register. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if consumers is null + */ + public AsyncSpec rootsChangeConsumers(List, Mono>> consumers) { + Assert.notNull(consumers, "Consumers list must not be null"); + this.rootsChangeConsumers.addAll(consumers); + return this; + } + + /** + * Registers multiple consumers that will be notified when the list of roots + * changes using varargs. This method provides a convenient way to register + * multiple consumers inline. + * @param consumers The consumers to register. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if consumers is null + */ + public AsyncSpec rootsChangeConsumers(Function, Mono>... consumers) { + for (Function, Mono> consumer : consumers) { + this.rootsChangeConsumers.add(consumer); + } + return this; + } + + /** + * Builds an asynchronous MCP server that provides non-blocking operations. + * @return A new instance of {@link McpAsyncServer} configured with this builder's + * settings + */ + public McpAsyncServer build() { + return new McpAsyncServer(this.transport, + new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, this.resources, + this.resourceTemplates, this.prompts, this.rootsChangeConsumers)); + } + + } + + /** + * Synchronous server specification. + */ + class SyncSpec { + + private static final McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server", + "1.0.0"); + + private final ServerMcpTransport transport; + + private McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO; + + private McpSchema.ServerCapabilities serverCapabilities; + + /** + * The Model Context Protocol (MCP) allows servers to expose tools that can be + * invoked by language models. Tools enable models to interact with external + * systems, such as querying databases, calling APIs, or performing computations. + * Each tool is uniquely identified by a name and includes metadata describing its + * schema. + */ + private final List tools = new ArrayList<>(); + + /** + * The Model Context Protocol (MCP) provides a standardized way for servers to + * expose resources to clients. Resources allow servers to share data that + * provides context to language models, such as files, database schemas, or + * application-specific information. Each resource is uniquely identified by a + * URI. + */ + private final Map resources = new HashMap<>(); + + private List resourceTemplates = new ArrayList<>(); + + /** + * The Model Context Protocol (MCP) provides a standardized way for servers to + * expose prompt templates to clients. Prompts allow servers to provide structured + * messages and instructions for interacting with language models. Clients can + * discover available prompts, retrieve their contents, and provide arguments to + * customize them. + */ + private Map prompts = new HashMap<>(); + + private List>> rootsChangeConsumers = new ArrayList<>(); + + private SyncSpec(ServerMcpTransport transport) { + Assert.notNull(transport, "Transport must not be null"); + this.transport = transport; + } + + /** + * Sets the server implementation information that will be shared with clients + * during connection initialization. This helps with version compatibility, + * debugging, and server identification. + * @param serverInfo The server implementation details including name and version. + * Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if serverInfo is null + */ + public SyncSpec serverInfo(McpSchema.Implementation serverInfo) { + Assert.notNull(serverInfo, "Server info must not be null"); + this.serverInfo = serverInfo; + return this; + } + + /** + * Sets the server implementation information using name and version strings. This + * is a convenience method alternative to + * {@link #serverInfo(McpSchema.Implementation)}. + * @param name The server name. Must not be null or empty. + * @param version The server version. Must not be null or empty. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if name or version is null or empty + * @see #serverInfo(McpSchema.Implementation) + */ + public SyncSpec serverInfo(String name, String version) { + Assert.hasText(name, "Name must not be null or empty"); + Assert.hasText(version, "Version must not be null or empty"); + this.serverInfo = new McpSchema.Implementation(name, version); + return this; + } + + /** + * Sets the server capabilities that will be advertised to clients during + * connection initialization. Capabilities define what features the server + * supports, such as: + *
      + *
    • Tool execution + *
    • Resource access + *
    • Prompt handling + *
    • Streaming responses + *
    • Batch operations + *
    + * @param serverCapabilities The server capabilities configuration. Must not be + * null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if serverCapabilities is null + */ + public SyncSpec capabilities(McpSchema.ServerCapabilities serverCapabilities) { + this.serverCapabilities = serverCapabilities; + return this; + } + + /** + * Adds a single tool with its implementation handler to the server. This is a + * convenience method for registering individual tools without creating a + * {@link ToolRegistration} explicitly. + * + *

    + * Example usage:

    {@code
    +		 * .tool(
    +		 *     new Tool("calculator", "Performs calculations", schema),
    +		 *     args -> new CallToolResult("Result: " + calculate(args))
    +		 * )
    +		 * }
    + * @param tool The tool definition including name, description, and schema. Must + * not be null. + * @param handler The function that implements the tool's logic. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if tool or handler is null + */ + public SyncSpec tool(McpSchema.Tool tool, Function, McpSchema.CallToolResult> handler) { + Assert.notNull(tool, "Tool must not be null"); + Assert.notNull(handler, "Handler must not be null"); + + this.tools.add(new McpServerFeatures.SyncToolRegistration(tool, handler)); + + return this; + } + + /** + * Adds multiple tools with their handlers to the server using a List. This method + * is useful when tools are dynamically generated or loaded from a configuration + * source. + * @param toolRegistrations The list of tool registrations to add. Must not be + * null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if toolRegistrations is null + * @see #tools(McpServerFeatures.SyncToolRegistration...) + */ + public SyncSpec tools(List toolRegistrations) { + Assert.notNull(toolRegistrations, "Tool handlers list must not be null"); + this.tools.addAll(toolRegistrations); + return this; + } + + /** + * Adds multiple tools with their handlers to the server using varargs. This + * method provides a convenient way to register multiple tools inline. + * + *

    + * Example usage:

    {@code
    +		 * .tools(
    +		 *     new ToolRegistration(calculatorTool, calculatorHandler),
    +		 *     new ToolRegistration(weatherTool, weatherHandler),
    +		 *     new ToolRegistration(fileManagerTool, fileManagerHandler)
    +		 * )
    +		 * }
    + * @param toolRegistrations The tool registrations to add. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if toolRegistrations is null + * @see #tools(List) + */ + public SyncSpec tools(McpServerFeatures.SyncToolRegistration... toolRegistrations) { + for (McpServerFeatures.SyncToolRegistration tool : toolRegistrations) { + this.tools.add(tool); + } + return this; + } + + /** + * Registers multiple resources with their handlers using a Map. This method is + * useful when resources are dynamically generated or loaded from a configuration + * source. + * @param resourceRegsitrations Map of resource name to registration. Must not be + * null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if resourceRegsitrations is null + * @see #resources(McpServerFeatures.SyncResourceRegistration...) + */ + public SyncSpec resources(Map resourceRegsitrations) { + Assert.notNull(resourceRegsitrations, "Resource handlers map must not be null"); + this.resources.putAll(resourceRegsitrations); + return this; + } + + /** + * Registers multiple resources with their handlers using a List. This method is + * useful when resources need to be added in bulk from a collection. + * @param resourceRegsitrations List of resource registrations. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if resourceRegsitrations is null + * @see #resources(McpServerFeatures.SyncResourceRegistration...) + */ + public SyncSpec resources(List resourceRegsitrations) { + Assert.notNull(resourceRegsitrations, "Resource handlers list must not be null"); + for (McpServerFeatures.SyncResourceRegistration resource : resourceRegsitrations) { + this.resources.put(resource.resource().uri(), resource); + } + return this; + } + + /** + * Registers multiple resources with their handlers using varargs. This method + * provides a convenient way to register multiple resources inline. + * + *

    + * Example usage:

    {@code
    +		 * .resources(
    +		 *     new ResourceRegistration(fileResource, fileHandler),
    +		 *     new ResourceRegistration(dbResource, dbHandler),
    +		 *     new ResourceRegistration(apiResource, apiHandler)
    +		 * )
    +		 * }
    + * @param resourceRegistrations The resource registrations to add. Must not be + * null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if resourceRegistrations is null + */ + public SyncSpec resources(McpServerFeatures.SyncResourceRegistration... resourceRegistrations) { + Assert.notNull(resourceRegistrations, "Resource handlers list must not be null"); + for (McpServerFeatures.SyncResourceRegistration resource : resourceRegistrations) { + this.resources.put(resource.resource().uri(), resource); + } + return this; + } + + /** + * Sets the resource templates that define patterns for dynamic resource access. + * Templates use URI patterns with placeholders that can be filled at runtime. + * + *

    + * Example usage:

    {@code
    +		 * .resourceTemplates(
    +		 *     new ResourceTemplate("file://{path}", "Access files by path"),
    +		 *     new ResourceTemplate("db://{table}/{id}", "Access database records")
    +		 * )
    +		 * }
    + * @param resourceTemplates List of resource templates. If null, clears existing + * templates. + * @return This builder instance for method chaining + * @see #resourceTemplates(ResourceTemplate...) + */ + public SyncSpec resourceTemplates(List resourceTemplates) { + this.resourceTemplates.addAll(resourceTemplates); + return this; + } + + /** + * Sets the resource templates using varargs for convenience. This is an + * alternative to {@link #resourceTemplates(List)}. + * @param resourceTemplates The resource templates to set. + * @return This builder instance for method chaining + * @see #resourceTemplates(List) + */ + public SyncSpec resourceTemplates(ResourceTemplate... resourceTemplates) { + for (ResourceTemplate resourceTemplate : resourceTemplates) { + this.resourceTemplates.add(resourceTemplate); + } + return this; + } + + /** + * Registers multiple prompts with their handlers using a Map. This method is + * useful when prompts are dynamically generated or loaded from a configuration + * source. + * + *

    + * Example usage:

    {@code
    +		 * Map prompts = new HashMap<>();
    +		 * prompts.put("analysis", new PromptRegistration(
    +		 *     new Prompt("analysis", "Code analysis template"),
    +		 *     request -> new GetPromptResult(generateAnalysisPrompt(request))
    +		 * ));
    +		 * .prompts(prompts)
    +		 * }
    + * @param prompts Map of prompt name to registration. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if prompts is null + */ + public SyncSpec prompts(Map prompts) { + this.prompts.putAll(prompts); + return this; + } + + /** + * Registers multiple prompts with their handlers using a List. This method is + * useful when prompts need to be added in bulk from a collection. + * @param prompts List of prompt registrations. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if prompts is null + * @see #prompts(McpServerFeatures.SyncPromptRegistration...) + */ + public SyncSpec prompts(List prompts) { + for (McpServerFeatures.SyncPromptRegistration prompt : prompts) { + this.prompts.put(prompt.prompt().name(), prompt); + } + return this; + } + + /** + * Registers multiple prompts with their handlers using varargs. This method + * provides a convenient way to register multiple prompts inline. + * + *

    + * Example usage:

    {@code
    +		 * .prompts(
    +		 *     new PromptRegistration(analysisPrompt, analysisHandler),
    +		 *     new PromptRegistration(summaryPrompt, summaryHandler),
    +		 *     new PromptRegistration(reviewPrompt, reviewHandler)
    +		 * )
    +		 * }
    + * @param prompts The prompt registrations to add. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if prompts is null + */ + public SyncSpec prompts(McpServerFeatures.SyncPromptRegistration... prompts) { + for (McpServerFeatures.SyncPromptRegistration prompt : prompts) { + this.prompts.put(prompt.prompt().name(), prompt); + } + return this; + } + + /** + * Registers a consumer that will be notified when the list of roots changes. This + * is useful for updating resource availability dynamically, such as when new + * files are added or removed. + * @param consumer The consumer to register. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if consumer is null + */ + public SyncSpec rootsChangeConsumer(Consumer> consumer) { + Assert.notNull(consumer, "Consumer must not be null"); + this.rootsChangeConsumers.add(consumer); + return this; + } + + /** + * Registers multiple consumers that will be notified when the list of roots + * changes. This method is useful when multiple consumers need to be registered at + * once. + * @param consumers The list of consumers to register. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if consumers is null + */ + public SyncSpec rootsChangeConsumers(List>> consumers) { + Assert.notNull(consumers, "Consumers list must not be null"); + this.rootsChangeConsumers.addAll(consumers); + return this; + } + + /** + * Registers multiple consumers that will be notified when the list of roots + * changes using varargs. This method provides a convenient way to register + * multiple consumers inline. + * @param consumers The consumers to register. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if consumers is null + */ + public SyncSpec rootsChangeConsumers(Consumer>... consumers) { + for (Consumer> consumer : consumers) { + this.rootsChangeConsumers.add(consumer); + } + return this; + } + + /** + * Builds a synchronous MCP server that provides blocking operations. + * @return A new instance of {@link McpSyncServer} configured with this builder's + * settings + */ + public McpSyncServer build() { + McpServerFeatures.Sync syncFeatures = new McpServerFeatures.Sync(this.serverInfo, this.serverCapabilities, + this.tools, this.resources, this.resourceTemplates, this.prompts, this.rootsChangeConsumers); + return new McpSyncServer( + new McpAsyncServer(this.transport, McpServerFeatures.Async.fromSync(syncFeatures))); + } + + } + + /** + * Builds a synchronous MCP server that provides blocking operations. Synchronous + * servers process each request to completion before handling the next one, making + * them simpler to implement but potentially less performant for concurrent + * operations. + * @return A new instance of {@link SyncSpec} for configuring the server. + */ + static SyncSpec sync(ServerMcpTransport transport) { + return new SyncSpec(transport); + } + + /** + * Builds an asynchronous MCP server that provides non-blocking operations. + * Asynchronous servers can handle multiple requests concurrently using a functional + * paradigm with non-blocking server transports, making them more efficient for + * high-concurrency scenarios but more complex to implement. + * @return A new instance of {@link SyncSpec} for configuring the server. + */ + static AsyncSpec async(ServerMcpTransport transport) { + return new AsyncSpec(transport); + } + + /** + * Builder class for creating MCP servers with custom configuration. + * + * @deprecated Use {@link #sync(ServerMcpTransport)} or + * {@link #async(ServerMcpTransport)} to create a server. + */ + @Deprecated public static class Builder { private static final McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server", @@ -576,7 +1294,9 @@ public interface McpServer { * operations. * @return A new instance of {@link McpSyncServer} configured with this builder's * settings + * @deprecated Use {@link #sync(ServerMcpTransport)}. */ + @Deprecated public McpSyncServer sync() { return new McpSyncServer(async()); } @@ -588,13 +1308,156 @@ public interface McpServer { * but more complex to implement. * @return A new instance of {@link McpAsyncServer} configured with this builder's * settings + * @deprecated Use {@link #async(ServerMcpTransport)} */ + @Deprecated public McpAsyncServer async() { - return new McpAsyncServer(transport, serverInfo, serverCapabilities, tools, resources, resourceTemplates, prompts, rootsChangeConsumers); } } + /** + * Registration of a tool with its handler function. Tools are the primary way for MCP + * servers to expose functionality to AI models. Each tool represents a specific + * capability, such as: + *
      + *
    • Performing calculations + *
    • Accessing external APIs + *
    • Querying databases + *
    • Manipulating files + *
    • Executing system commands + *
    + * + *

    + * Example tool registration:

    {@code
    +	 * new ToolRegistration(
    +	 *     new Tool(
    +	 *         "calculator",
    +	 *         "Performs mathematical calculations",
    +	 *         new JsonSchemaObject()
    +	 *             .required("expression")
    +	 *             .property("expression", JsonSchemaType.STRING)
    +	 *     ),
    +	 *     args -> {
    +	 *         String expr = (String) args.get("expression");
    +	 *         return new CallToolResult("Result: " + evaluate(expr));
    +	 *     }
    +	 * )
    +	 * }
    + * + * @param tool The tool definition including name, description, and parameter schema + * @param call The function that implements the tool's logic, receiving arguments and + * returning results + * @deprecated Use {@link McpServerFeatures.SyncToolRegistration} or + * {@link McpServerFeatures.AsyncToolRegistration}. + */ + @Deprecated + public static record ToolRegistration(McpSchema.Tool tool, + Function, McpSchema.CallToolResult> call) { + } + + /** + * Registration of a resource with its handler function. Resources provide context to + * AI models by exposing data such as: + *
      + *
    • File contents + *
    • Database records + *
    • API responses + *
    • System information + *
    • Application state + *
    + * + *

    + * Example resource registration:

    {@code
    +	 * new ResourceRegistration(
    +	 *     new Resource("docs", "Documentation files", "text/markdown"),
    +	 *     request -> {
    +	 *         String content = readFile(request.getPath());
    +	 *         return new ReadResourceResult(content);
    +	 *     }
    +	 * )
    +	 * }
    + * + * @param resource The resource definition including name, description, and MIME type + * @param readHandler The function that handles resource read requests + * @deprecated Use {@link McpServerFeatures.SyncResourceRegistration} or + * {@link McpServerFeatures.AsyncResourceRegistration}. + */ + @Deprecated + public static record ResourceRegistration(McpSchema.Resource resource, + Function readHandler) { + } + + /** + * Registration of a prompt template with its handler function. Prompts provide + * structured templates for AI model interactions, supporting: + *
      + *
    • Consistent message formatting + *
    • Parameter substitution + *
    • Context injection + *
    • Response formatting + *
    • Instruction templating + *
    + * + *

    + * Example prompt registration:

    {@code
    +	 * new PromptRegistration(
    +	 *     new Prompt("analyze", "Code analysis template"),
    +	 *     request -> {
    +	 *         String code = request.getArguments().get("code");
    +	 *         return new GetPromptResult(
    +	 *             "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
    +	 *         );
    +	 *     }
    +	 * )
    +	 * }
    + * + * @param prompt The prompt definition including name and description + * @param promptHandler The function that processes prompt requests and returns + * formatted templates + * @deprecated Use {@link McpServerFeatures.SyncPromptRegistration} or + * {@link McpServerFeatures.AsyncPromptRegistration}. + */ + @Deprecated + public static record PromptRegistration(McpSchema.Prompt prompt, + Function promptHandler) { + } + + static McpServerFeatures.AsyncToolRegistration mapDeprecatedTool(ToolRegistration oldTool) { + return new McpServerFeatures.AsyncToolRegistration(oldTool.tool(), + map -> Mono.fromCallable(() -> oldTool.call().apply(map)).subscribeOn(Schedulers.boundedElastic())); + } + + static McpServerFeatures.AsyncResourceRegistration mapDeprecatedResource(ResourceRegistration oldResource) { + return new McpServerFeatures.AsyncResourceRegistration(oldResource.resource(), + req -> Mono.fromCallable(() -> oldResource.readHandler().apply(req)) + .subscribeOn(Schedulers.boundedElastic())); + } + + static McpServerFeatures.AsyncPromptRegistration mapDeprecatedPrompt(PromptRegistration oldPrompt) { + return new McpServerFeatures.AsyncPromptRegistration(oldPrompt.prompt(), + req -> Mono.fromCallable(() -> oldPrompt.promptHandler().apply(req)) + .subscribeOn(Schedulers.boundedElastic())); + } + + static List mapDeprecatedTools(List oldTools) { + return oldTools.stream().map(McpServer::mapDeprecatedTool).toList(); + } + + static Map mapDeprecatedResources( + Map oldResources) { + return oldResources.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> mapDeprecatedResource(e.getValue()))); + } + + static Map mapDeprecatedPrompts( + Map oldPrompts) { + return oldPrompts.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> mapDeprecatedPrompt(e.getValue()))); + } + } diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServerFeatures.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServerFeatures.java new file mode 100644 index 0000000..0faba95 --- /dev/null +++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServerFeatures.java @@ -0,0 +1,399 @@ +package org.springframework.ai.mcp.server; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.util.Assert; +import org.springframework.ai.mcp.util.Utils; + +/** + * MCP server features specification that a particular server can choose to support. + */ +public class McpServerFeatures { + + /** + * Asynchronous server features specification. + * + * @param serverInfo The server implementation details + * @param serverCapabilities The server capabilities + * @param tools The list of tool registrations + * @param resources The map of resource registrations + * @param resourceTemplates The list of resource templates + * @param prompts The map of prompt registrations + * @param rootsChangeConsumers The list of consumers that will be notified when the + * roots list changes + */ + record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, + List tools, Map resources, + List resourceTemplates, + Map prompts, + List, Mono>> rootsChangeConsumers) { + + /** + * Create an instance and validate the arguments. + * @param serverInfo The server implementation details + * @param serverCapabilities The server capabilities + * @param tools The list of tool registrations + * @param resources The map of resource registrations + * @param resourceTemplates The list of resource templates + * @param prompts The map of prompt registrations + * @param rootsChangeConsumers The list of consumers that will be notified when + * the roots list changes + */ + Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, + List tools, Map resources, + List resourceTemplates, + Map prompts, + List, Mono>> rootsChangeConsumers) { + + Assert.notNull(serverInfo, "Server info must not be null"); + + this.serverInfo = serverInfo; + this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities + : new McpSchema.ServerCapabilities(null, // experimental + new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable + // logging + // by + // default + !Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null, + !Utils.isEmpty(resources) + ? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null, + !Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null); + + this.tools = (tools != null) ? tools : List.of(); + this.resources = (resources != null) ? resources : Map.of(); + this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : List.of(); + this.prompts = (prompts != null) ? prompts : Map.of(); + this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : List.of(); + } + + /** + * Convert a synchronous specification into an asynchronous one and provide + * blocking code offloading to prevent accidental blocking of the non-blocking + * transport. + * @param syncSpec a potentially blocking, synchronous specification. + * @return a specification which is protected from blocking calls specified by the + * user. + */ + static Async fromSync(Sync syncSpec) { + List tools = new ArrayList<>(); + for (var tool : syncSpec.tools()) { + tools.add(AsyncToolRegistration.fromSync(tool)); + } + + Map resources = new HashMap<>(); + syncSpec.resources().forEach((key, resource) -> { + resources.put(key, AsyncResourceRegistration.fromSync(resource)); + }); + + Map prompts = new HashMap<>(); + syncSpec.prompts().forEach((key, prompt) -> { + prompts.put(key, AsyncPromptRegistration.fromSync(prompt)); + }); + + List, Mono>> rootChangeConsumers = new ArrayList<>(); + + for (var rootChangeConsumer : syncSpec.rootsChangeConsumers()) { + rootChangeConsumers.add(list -> Mono.fromRunnable(() -> rootChangeConsumer.accept(list)) + .subscribeOn(Schedulers.boundedElastic())); + } + + return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, + syncSpec.resourceTemplates(), prompts, rootChangeConsumers); + } + } + + /** + * Synchronous server features specification. + * + * @param serverInfo The server implementation details + * @param serverCapabilities The server capabilities + * @param tools The list of tool registrations + * @param resources The map of resource registrations + * @param resourceTemplates The list of resource templates + * @param prompts The map of prompt registrations + * @param rootsChangeConsumers The list of consumers that will be notified when the + * roots list changes + */ + record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, + List tools, + Map resources, + List resourceTemplates, + Map prompts, + List>> rootsChangeConsumers) { + + /** + * Create an instance and validate the arguments. + * @param serverInfo The server implementation details + * @param serverCapabilities The server capabilities + * @param tools The list of tool registrations + * @param resources The map of resource registrations + * @param resourceTemplates The list of resource templates + * @param prompts The map of prompt registrations + * @param rootsChangeConsumers The list of consumers that will be notified when + * the roots list changes + */ + Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, + List tools, + Map resources, + List resourceTemplates, + Map prompts, + List>> rootsChangeConsumers) { + + Assert.notNull(serverInfo, "Server info must not be null"); + + this.serverInfo = serverInfo; + this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities + : new McpSchema.ServerCapabilities(null, // experimental + new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable + // logging + // by + // default + !Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null, + !Utils.isEmpty(resources) + ? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null, + !Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null); + + this.tools = (tools != null) ? tools : new ArrayList<>(); + this.resources = (resources != null) ? resources : new HashMap<>(); + this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : new ArrayList<>(); + this.prompts = (prompts != null) ? prompts : new HashMap<>(); + this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : new ArrayList<>(); + } + + } + + /** + * Registration of a tool with its asynchronous handler function. Tools are the + * primary way for MCP servers to expose functionality to AI models. Each tool + * represents a specific capability, such as: + *
      + *
    • Performing calculations + *
    • Accessing external APIs + *
    • Querying databases + *
    • Manipulating files + *
    • Executing system commands + *
    + * + *

    + * Example tool registration:

    {@code
    +	 * new McpServerFeatures.AsyncToolRegistration(
    +	 *     new Tool(
    +	 *         "calculator",
    +	 *         "Performs mathematical calculations",
    +	 *         new JsonSchemaObject()
    +	 *             .required("expression")
    +	 *             .property("expression", JsonSchemaType.STRING)
    +	 *     ),
    +	 *     args -> {
    +	 *         String expr = (String) args.get("expression");
    +	 *         return Mono.just(new CallToolResult("Result: " + evaluate(expr)));
    +	 *     }
    +	 * )
    +	 * }
    + * + * @param tool The tool definition including name, description, and parameter schema + * @param call The function that implements the tool's logic, receiving arguments and + * returning results + */ + public record AsyncToolRegistration(McpSchema.Tool tool, + Function, Mono> call) { + + static AsyncToolRegistration fromSync(SyncToolRegistration tool) { + // FIXME: This is temporary, proper validation should be implemented + if (tool == null) { + return null; + } + return new AsyncToolRegistration(tool.tool(), + map -> Mono.fromCallable(() -> tool.call().apply(map)).subscribeOn(Schedulers.boundedElastic())); + } + } + + /** + * Registration of a resource with its asynchronous handler function. Resources + * provide context to AI models by exposing data such as: + *
      + *
    • File contents + *
    • Database records + *
    • API responses + *
    • System information + *
    • Application state + *
    + * + *

    + * Example resource registration:

    {@code
    +	 * new McpServerFeatures.AsyncResourceRegistration(
    +	 *     new Resource("docs", "Documentation files", "text/markdown"),
    +	 *     request -> {
    +	 *         String content = readFile(request.getPath());
    +	 *         return Mono.just(new ReadResourceResult(content));
    +	 *     }
    +	 * )
    +	 * }
    + * + * @param resource The resource definition including name, description, and MIME type + * @param readHandler The function that handles resource read requests + */ + public record AsyncResourceRegistration(McpSchema.Resource resource, + Function> readHandler) { + + static AsyncResourceRegistration fromSync(SyncResourceRegistration resource) { + // FIXME: This is temporary, proper validation should be implemented + if (resource == null) { + return null; + } + return new AsyncResourceRegistration(resource.resource(), + req -> Mono.fromCallable(() -> resource.readHandler().apply(req)) + .subscribeOn(Schedulers.boundedElastic())); + } + } + + /** + * Registration of a prompt template with its asynchronous handler function. Prompts + * provide structured templates for AI model interactions, supporting: + *
      + *
    • Consistent message formatting + *
    • Parameter substitution + *
    • Context injection + *
    • Response formatting + *
    • Instruction templating + *
    + * + *

    + * Example prompt registration:

    {@code
    +	 * new McpServerFeatures.AsyncPromptRegistration(
    +	 *     new Prompt("analyze", "Code analysis template"),
    +	 *     request -> {
    +	 *         String code = request.getArguments().get("code");
    +	 *         return Mono.just(new GetPromptResult(
    +	 *             "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
    +	 *         ));
    +	 *     }
    +	 * )
    +	 * }
    + * + * @param prompt The prompt definition including name and description + * @param promptHandler The function that processes prompt requests and returns + * formatted templates + */ + public record AsyncPromptRegistration(McpSchema.Prompt prompt, + Function> promptHandler) { + + static AsyncPromptRegistration fromSync(SyncPromptRegistration prompt) { + // FIXME: This is temporary, proper validation should be implemented + if (prompt == null) { + return null; + } + return new AsyncPromptRegistration(prompt.prompt(), + req -> Mono.fromCallable(() -> prompt.promptHandler().apply(req)) + .subscribeOn(Schedulers.boundedElastic())); + } + } + + /** + * Registration of a tool with its synchronous handler function. Tools are the primary + * way for MCP servers to expose functionality to AI models. Each tool represents a + * specific capability, such as: + *
      + *
    • Performing calculations + *
    • Accessing external APIs + *
    • Querying databases + *
    • Manipulating files + *
    • Executing system commands + *
    + * + *

    + * Example tool registration:

    {@code
    +	 * new McpServerFeatures.SyncToolRegistration(
    +	 *     new Tool(
    +	 *         "calculator",
    +	 *         "Performs mathematical calculations",
    +	 *         new JsonSchemaObject()
    +	 *             .required("expression")
    +	 *             .property("expression", JsonSchemaType.STRING)
    +	 *     ),
    +	 *     args -> {
    +	 *         String expr = (String) args.get("expression");
    +	 *         return new CallToolResult("Result: " + evaluate(expr));
    +	 *     }
    +	 * )
    +	 * }
    + * + * @param tool The tool definition including name, description, and parameter schema + * @param call The function that implements the tool's logic, receiving arguments and + * returning results + */ + public record SyncToolRegistration(McpSchema.Tool tool, + Function, McpSchema.CallToolResult> call) { + } + + /** + * Registration of a resource with its synchronous handler function. Resources provide + * context to AI models by exposing data such as: + *
      + *
    • File contents + *
    • Database records + *
    • API responses + *
    • System information + *
    • Application state + *
    + * + *

    + * Example resource registration:

    {@code
    +	 * new McpServerFeatures.SyncResourceRegistration(
    +	 *     new Resource("docs", "Documentation files", "text/markdown"),
    +	 *     request -> {
    +	 *         String content = readFile(request.getPath());
    +	 *         return new ReadResourceResult(content);
    +	 *     }
    +	 * )
    +	 * }
    + * + * @param resource The resource definition including name, description, and MIME type + * @param readHandler The function that handles resource read requests + */ + public record SyncResourceRegistration(McpSchema.Resource resource, + Function readHandler) { + } + + /** + * Registration of a prompt template with its synchronous handler function. Prompts + * provide structured templates for AI model interactions, supporting: + *
      + *
    • Consistent message formatting + *
    • Parameter substitution + *
    • Context injection + *
    • Response formatting + *
    • Instruction templating + *
    + * + *

    + * Example prompt registration:

    {@code
    +	 * new McpServerFeatures.SyncPromptRegistration(
    +	 *     new Prompt("analyze", "Code analysis template"),
    +	 *     request -> {
    +	 *         String code = request.getArguments().get("code");
    +	 *         return new GetPromptResult(
    +	 *             "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
    +	 *         );
    +	 *     }
    +	 * )
    +	 * }
    + * + * @param prompt The prompt definition including name and description + * @param promptHandler The function that processes prompt requests and returns + * formatted templates + */ + public record SyncPromptRegistration(McpSchema.Prompt prompt, + Function promptHandler) { + } + +} diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java index db564f0..97c949f 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java @@ -26,11 +26,42 @@ import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification; import org.springframework.ai.mcp.util.Assert; /** - * Synchronous wrapper around {@link McpAsyncServer} that provides blocking operations. - * This class delegates all operations to an underlying async server instance while - * providing a synchronous API. + * A synchronous implementation of the Model Context Protocol (MCP) server that wraps + * {@link McpAsyncServer} to provide blocking operations. This class delegates all + * operations to an underlying async server instance while providing a simpler, + * synchronous API for scenarios where reactive programming is not required. + * + *

    + * The MCP server enables AI models to expose tools, resources, and prompts through a + * standardized interface. Key features available through this synchronous API include: + *

      + *
    • Tool registration and management for extending AI model capabilities + *
    • Resource handling with URI-based addressing for providing context + *
    • Prompt template management for standardized interactions + *
    • Real-time client notifications for state changes + *
    • Structured logging with configurable severity levels + *
    • Support for client-side AI model sampling + *
    + * + *

    + * While {@link McpAsyncServer} uses Project Reactor's Mono and Flux types for + * non-blocking operations, this class converts those into blocking calls, making it more + * suitable for: + *

      + *
    • Traditional synchronous applications + *
    • Simple scripting scenarios + *
    • Testing and debugging + *
    • Cases where reactive programming adds unnecessary complexity + *
    + * + *

    + * The server supports runtime modification of its capabilities through methods like + * {@link #addTool}, {@link #addResource}, and {@link #addPrompt}, automatically notifying + * connected clients of changes when configured to do so. * * @author Christian Tzolov + * @see McpAsyncServer + * @see McpSchema */ public class McpSyncServer { @@ -69,6 +100,16 @@ public class McpSyncServer { * Add a new tool handler. * @param toolHandler The tool handler to add */ + public void addTool(McpServerFeatures.SyncToolRegistration toolHandler) { + this.asyncServer.addTool(McpServerFeatures.AsyncToolRegistration.fromSync(toolHandler)).block(); + } + + /** + * Add a new tool handler. + * @param toolHandler The tool handler to add + * @deprecated Use {@link #addTool(McpServerFeatures.SyncToolRegistration)}. + */ + @Deprecated public void addTool(ToolRegistration toolHandler) { this.asyncServer.addTool(toolHandler).block(); } @@ -85,6 +126,16 @@ public class McpSyncServer { * Add a new resource handler. * @param resourceHandler The resource handler to add */ + public void addResource(McpServerFeatures.SyncResourceRegistration resourceHandler) { + this.asyncServer.addResource(McpServerFeatures.AsyncResourceRegistration.fromSync(resourceHandler)).block(); + } + + /** + * Add a new resource handler. + * @param resourceHandler The resource handler to add + * @deprecated Use {@link #addResource(McpServerFeatures.SyncResourceRegistration)}. + */ + @Deprecated public void addResource(ResourceRegistration resourceHandler) { this.asyncServer.addResource(resourceHandler).block(); } @@ -101,6 +152,16 @@ public class McpSyncServer { * Add a new prompt handler. * @param promptRegistration The prompt registration to add */ + public void addPrompt(McpServerFeatures.SyncPromptRegistration promptRegistration) { + this.asyncServer.addPrompt(McpServerFeatures.AsyncPromptRegistration.fromSync(promptRegistration)).block(); + } + + /** + * Add a new prompt handler. + * @param promptRegistration The prompt registration to add + * @deprecated Use {@link #addPrompt(McpServerFeatures.SyncPromptRegistration)}. + */ + @Deprecated public void addPrompt(PromptRegistration promptRegistration) { this.asyncServer.addPrompt(promptRegistration).block(); } @@ -120,28 +181,6 @@ public class McpSyncServer { this.asyncServer.notifyToolsListChanged().block(); } - /** - * Notify clients that the list of available resources has changed. - */ - public void notifyResourcesListChanged() { - this.asyncServer.notifyResourcesListChanged().block(); - } - - /** - * Notify clients that the list of available prompts has changed. - */ - public void notifyPromptsListChanged() { - this.asyncServer.notifyPromptsListChanged().block(); - } - - /** - * Send a logging message notification to all clients. - * @param loggingMessageNotification The logging message notification to send - */ - public void loggingNotification(LoggingMessageNotification loggingMessageNotification) { - this.asyncServer.loggingNotification(loggingMessageNotification).block(); - } - /** * Get the server capabilities that define the supported features and functionality. * @return The server capabilities @@ -174,6 +213,28 @@ public class McpSyncServer { return this.asyncServer.getClientInfo(); } + /** + * Notify clients that the list of available resources has changed. + */ + public void notifyResourcesListChanged() { + this.asyncServer.notifyResourcesListChanged().block(); + } + + /** + * Notify clients that the list of available prompts has changed. + */ + public void notifyPromptsListChanged() { + this.asyncServer.notifyPromptsListChanged().block(); + } + + /** + * Send a logging message notification to all clients. + * @param loggingMessageNotification The logging message notification to send + */ + public void loggingNotification(LoggingMessageNotification loggingMessageNotification) { + this.asyncServer.loggingNotification(loggingMessageNotification).block(); + } + /** * Close the server gracefully. */ @@ -199,13 +260,19 @@ public class McpSyncServer { /** * Create a new message using the sampling capabilities of the client. The Model * Context Protocol (MCP) provides a standardized way for servers to request LLM - * sampling (“completions” or “generations”) from language models via clients. This - * flow allows clients to maintain control over model access, selection, and + * sampling ("completions" or "generations") from language models via clients. + * + *

    + * This flow allows clients to maintain control over model access, selection, and * permissions while enabling servers to leverage AI capabilities—with no server API * keys necessary. Servers can request text or image-based interactions and optionally * include context from MCP servers in their prompts. + * + *

    + * Unlike its async counterpart, this method blocks until the message creation is + * complete, making it easier to use in synchronous code paths. * @param createMessageRequest The request to create a new message - * @return A Mono that completes when the message has been created + * @return The result of the message creation * @throws McpError if the client has not been initialized or does not support * sampling capabilities * @throws McpError if the client does not support the createMessage method diff --git a/mcp/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java b/mcp/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java index 8a1dde0..fffd710 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java @@ -63,7 +63,7 @@ public class DefaultMcpSession implements McpSession { private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>(); /** Map of request handlers keyed by method name */ - private final ConcurrentHashMap requestHandlers = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> requestHandlers = new ConcurrentHashMap<>(); /** Map of notification handlers keyed by method name */ private final ConcurrentHashMap notificationHandlers = new ConcurrentHashMap<>(); @@ -79,16 +79,18 @@ public class DefaultMcpSession implements McpSession { /** * Functional interface for handling incoming JSON-RPC requests. Implementations * should process the request parameters and return a response. + * + * @param Response type */ @FunctionalInterface - public interface RequestHandler { + public interface RequestHandler { /** * Handles an incoming request with the given parameters. * @param params The request parameters * @return A Mono containing the response object */ - Mono handle(Object params); + Mono handle(Object params); } @@ -116,7 +118,7 @@ public class DefaultMcpSession implements McpSession { * @param notificationHandlers Map of method names to notification handlers */ public DefaultMcpSession(Duration requestTimeout, McpTransport transport, - Map requestHandlers, Map notificationHandlers) { + Map> requestHandlers, Map notificationHandlers) { Assert.notNull(requestTimeout, "The requstTimeout can not be null"); Assert.notNull(transport, "The transport can not be null"); diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java index b5a00bf..2579f57 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import org.springframework.ai.mcp.spec.ClientMcpTransport; @@ -77,10 +78,10 @@ public abstract class AbstractMcpAsyncClientTests { this.mcpTransport = createMcpTransport(); assertThatCode(() -> { - mcpAsyncClient = McpClient.using(mcpTransport) + mcpAsyncClient = McpClient.async(mcpTransport) .requestTimeout(TIMEOUT) .capabilities(ClientCapabilities.builder().roots(true).build()) - .async(); + .build(); mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); }).doesNotThrowAnyException(); } @@ -96,10 +97,10 @@ public abstract class AbstractMcpAsyncClientTests { @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync()) + assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } @@ -195,10 +196,10 @@ public abstract class AbstractMcpAsyncClientTests { void testInitializeWithRootsListProviders() { var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.async(transport) .requestTimeout(TIMEOUT) .roots(new Root("file:///test/path", "test-root")) - .async(); + .build(); assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); @@ -277,12 +278,12 @@ public abstract class AbstractMcpAsyncClientTests { AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.async(transport) .requestTimeout(TIMEOUT) - .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) - .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) - .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) - .async(); + .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(); @@ -299,9 +300,11 @@ public abstract class AbstractMcpAsyncClientTests { var capabilities = ClientCapabilities.builder().sampling().build(); - var client = McpClient.using(transport).requestTimeout(TIMEOUT).capabilities(capabilities).sampling(request -> { - return CreateMessageResult.builder().message("test").model("test-model").build(); - }).async(); + var client = McpClient.async(transport) + .requestTimeout(TIMEOUT) + .capabilities(capabilities) + .sampling(request -> Mono.just(CreateMessageResult.builder().message("test").model("test-model").build())) + .build(); assertThatCode(() -> { client.initialize().block(Duration.ofSeconds(10)); @@ -319,14 +322,13 @@ public abstract class AbstractMcpAsyncClientTests { .sampling() .build(); - Function samplingHandler = request -> { - return CreateMessageResult.builder().message("test").model("test-model").build(); - }; - var client = McpClient.using(transport) + Function> samplingHandler = request -> Mono + .just(CreateMessageResult.builder().message("test").model("test-model").build()); + var client = McpClient.async(transport) .requestTimeout(TIMEOUT) .capabilities(capabilities) .sampling(samplingHandler) - .async(); + .build(); assertThatCode(() -> { var result = client.initialize().block(Duration.ofSeconds(10)); @@ -353,10 +355,10 @@ public abstract class AbstractMcpAsyncClientTests { AtomicBoolean logReceived = new AtomicBoolean(false); var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.async(transport) .requestTimeout(TIMEOUT) - .loggingConsumer(notification -> logReceived.set(true)) - .async(); + .loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))) + .build(); assertThatCode(() -> { client.initialize().block(Duration.ofSeconds(10)); diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java index 9935f7f..c012612 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java @@ -72,10 +72,10 @@ public abstract class AbstractMcpSyncClientTests { this.mcpTransport = createMcpTransport(); assertThatCode(() -> { - mcpSyncClient = McpClient.using(mcpTransport) + mcpSyncClient = McpClient.sync(mcpTransport) .requestTimeout(TIMEOUT) .capabilities(ClientCapabilities.builder().roots(true).build()) - .sync(); + .build(); mcpSyncClient.initialize(); }).doesNotThrowAnyException(); } @@ -90,10 +90,10 @@ public abstract class AbstractMcpSyncClientTests { @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync()) + assertThatThrownBy(() -> McpClient.sync(mcpTransport).requestTimeout(null).build()) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Request timeout must not be null"); } @@ -180,10 +180,10 @@ public abstract class AbstractMcpSyncClientTests { void testInitializeWithRootsListProviders() { var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.sync(transport) .requestTimeout(TIMEOUT) .roots(new Root("file:///test/path", "test-root")) - .sync(); + .build(); assertThatCode(() -> { client.initialize(); @@ -262,12 +262,12 @@ public abstract class AbstractMcpSyncClientTests { AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.sync(transport) .requestTimeout(TIMEOUT) .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) - .sync(); + .build(); assertThatCode(() -> { client.initialize(); @@ -295,10 +295,10 @@ public abstract class AbstractMcpSyncClientTests { AtomicBoolean logReceived = new AtomicBoolean(false); var transport = createMcpTransport(); - var client = McpClient.using(transport) + var client = McpClient.sync(transport) .requestTimeout(TIMEOUT) .loggingConsumer(notification -> logReceived.set(true)) - .sync(); + .build(); assertThatCode(() -> { client.initialize(); diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java index 6c67b99..2922bf0 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java @@ -27,6 +27,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import org.springframework.ai.mcp.MockMcpTransport; import org.springframework.ai.mcp.spec.McpError; @@ -48,12 +49,11 @@ class McpAsyncClientResponseHandlerTests { List receivedTools = new ArrayList<>(); // Create a consumer that will be called when tools change - Consumer> toolsChangeConsumer = tools -> { - receivedTools.addAll(tools); - }; + Function, Mono> toolsChangeConsumer = tools -> Mono + .fromRunnable(() -> receivedTools.addAll(tools)); // Create client with tools change consumer - McpAsyncClient asyncMcpClient = McpClient.using(transport).toolsChangeConsumer(toolsChangeConsumer).async(); + McpAsyncClient asyncMcpClient = McpClient.async(transport).toolsChangeConsumer(toolsChangeConsumer).build(); // Create a mock tools list that the server will return Map inputSchema = Map.of("type", "object", "properties", Map.of(), "required", List.of()); @@ -88,9 +88,9 @@ class McpAsyncClientResponseHandlerTests { void testRootsListRequestHandling() { MockMcpTransport transport = new MockMcpTransport(); - McpAsyncClient asyncMcpClient = McpClient.using(transport) + McpAsyncClient asyncMcpClient = McpClient.async(transport) .roots(new Root("file:///test/path", "test-root")) - .async(); + .build(); // Simulate incoming request McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, @@ -118,14 +118,13 @@ class McpAsyncClientResponseHandlerTests { List receivedResources = new ArrayList<>(); // Create a consumer that will be called when resources change - Consumer> resourcesChangeConsumer = resources -> { - receivedResources.addAll(resources); - }; + Function, Mono> resourcesChangeConsumer = resources -> Mono + .fromRunnable(() -> receivedResources.addAll(resources)); // Create client with resources change consumer - McpAsyncClient asyncMcpClient = McpClient.using(transport) + McpAsyncClient asyncMcpClient = McpClient.async(transport) .resourcesChangeConsumer(resourcesChangeConsumer) - .async(); + .build(); // Create a mock resources list that the server will return McpSchema.Resource mockResource = new McpSchema.Resource("test://resource", "Test Resource", "A test resource", @@ -165,12 +164,11 @@ class McpAsyncClientResponseHandlerTests { List receivedPrompts = new ArrayList<>(); // Create a consumer that will be called when prompts change - Consumer> promptsChangeConsumer = prompts -> { - receivedPrompts.addAll(prompts); - }; + Function, Mono> promptsChangeConsumer = prompts -> Mono + .fromRunnable(() -> receivedPrompts.addAll(prompts)); // Create client with prompts change consumer - McpAsyncClient asyncMcpClient = McpClient.using(transport).promptsChangeConsumer(promptsChangeConsumer).async(); + McpAsyncClient asyncMcpClient = McpClient.async(transport).promptsChangeConsumer(promptsChangeConsumer).build(); // Create a mock prompts list that the server will return McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "Test Prompt Description", @@ -207,17 +205,17 @@ class McpAsyncClientResponseHandlerTests { MockMcpTransport transport = new MockMcpTransport(); // Create a test sampling handler that echoes back the input - Function samplingHandler = request -> { + Function> samplingHandler = request -> { var content = request.messages().get(0).content(); - return new McpSchema.CreateMessageResult(McpSchema.Role.ASSISTANT, content, "test-model", - McpSchema.CreateMessageResult.StopReason.END_TURN); + return Mono.just(new McpSchema.CreateMessageResult(McpSchema.Role.ASSISTANT, content, "test-model", + McpSchema.CreateMessageResult.StopReason.END_TURN)); }; // Create client with sampling capability and handler - McpAsyncClient asyncMcpClient = McpClient.using(transport) + McpAsyncClient asyncMcpClient = McpClient.async(transport) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) - .async(); + .build(); // Create a mock create message request var messageRequest = new McpSchema.CreateMessageRequest( @@ -259,9 +257,9 @@ class McpAsyncClientResponseHandlerTests { MockMcpTransport transport = new MockMcpTransport(); // Create client without sampling capability - McpAsyncClient asyncMcpClient = McpClient.using(transport) + McpAsyncClient asyncMcpClient = McpClient.async(transport) .capabilities(ClientCapabilities.builder().build()) // No sampling capability - .async(); + .build(); // Create a mock create message request var messageRequest = new McpSchema.CreateMessageRequest( @@ -292,7 +290,7 @@ class McpAsyncClientResponseHandlerTests { // Create client with sampling capability but null handler assertThatThrownBy( - () -> McpClient.using(transport).capabilities(ClientCapabilities.builder().sampling().build()).async()) + () -> McpClient.async(transport).capabilities(ClientCapabilities.builder().sampling().build()).build()) .isInstanceOf(McpError.class) .hasMessage("Sampling handler must not be null when client capabilities include sampling"); } diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java index 7da6775..0c7c1aa 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java @@ -22,11 +22,9 @@ import java.util.List; 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.test.StepVerifier; -import org.springframework.ai.mcp.server.McpServer.PromptRegistration; -import org.springframework.ai.mcp.server.McpServer.ResourceRegistration; -import org.springframework.ai.mcp.server.McpServer.ToolRegistration; import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; @@ -81,24 +79,24 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpServer.async(null)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) + assertThatThrownBy(() -> McpServer.async(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Server info must not be null"); } @Test void testGracefulShutdown() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete(); } @Test void testImmediateClose() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException(); } @@ -117,81 +115,80 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testAddTool() { Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .async(); + .build(); - StepVerifier - .create(mcpAsyncServer.addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false)))) + StepVerifier.create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolRegistration(newTool, + args -> Mono.just(new CallToolResult(List.of(), false))))) .verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testAddDuplicateTool() { Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(duplicateTool, args -> new CallToolResult(List.of(), false)) - .async(); + .tool(duplicateTool, args -> Mono.just(new CallToolResult(List.of(), false))) + .build(); - StepVerifier - .create(mcpAsyncServer - .addTool(new ToolRegistration(duplicateTool, args -> new CallToolResult(List.of(), false)))) + StepVerifier.create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolRegistration(duplicateTool, + args -> Mono.just(new CallToolResult(List.of(), false))))) .verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists"); }); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testRemoveTool() { Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(too, args -> new CallToolResult(List.of(), false)) - .async(); + .tool(too, args -> Mono.just(new CallToolResult(List.of(), false))) + .build(); StepVerifier.create(mcpAsyncServer.removeTool(TEST_TOOL_NAME)).verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testRemoveNonexistentTool() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .async(); + .build(); StepVerifier.create(mcpAsyncServer.removeTool("nonexistent-tool")).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class).hasMessage("Tool with name 'nonexistent-tool' not found"); }); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testNotifyToolsListChanged() { Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(too, args -> new CallToolResult(List.of(), false)) - .async(); + .tool(too, args -> Mono.just(new CallToolResult(List.of(), false))) + .build(); StepVerifier.create(mcpAsyncServer.notifyToolsListChanged()).verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } // --------------------------------------- @@ -200,24 +197,24 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testNotifyResourcesListChanged() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testAddResource() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .async(); + .build(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", null); - ResourceRegistration registration = new ResourceRegistration(resource, - req -> new ReadResourceResult(List.of())); + McpServerFeatures.AsyncResourceRegistration registration = new McpServerFeatures.AsyncResourceRegistration( + resource, req -> Mono.just(new ReadResourceResult(List.of()))); StepVerifier.create(mcpAsyncServer.addResource(registration)).verifyComplete(); @@ -226,14 +223,15 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testAddResourceWithNullRegistration() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .async(); + .build(); - StepVerifier.create(mcpAsyncServer.addResource(null)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource must not be null"); - }); + StepVerifier.create(mcpAsyncServer.addResource((McpServerFeatures.AsyncResourceRegistration) null)) + .verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource must not be null"); + }); assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @@ -241,14 +239,14 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testAddResourceWithoutCapability() { // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport()) + McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .async(); + .build(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", null); - ResourceRegistration registration = new ResourceRegistration(resource, - req -> new ReadResourceResult(List.of())); + McpServerFeatures.AsyncResourceRegistration registration = new McpServerFeatures.AsyncResourceRegistration( + resource, req -> Mono.just(new ReadResourceResult(List.of()))); StepVerifier.create(serverWithoutResources.addResource(registration)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -259,9 +257,9 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testRemoveResourceWithoutCapability() { // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport()) + McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .async(); + .build(); StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -275,36 +273,37 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testNotifyPromptsListChanged() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete(); - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } @Test void testAddPromptWithNullRegistration() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(false).build()) - .async(); + .build(); - StepVerifier.create(mcpAsyncServer.addPrompt(null)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null"); - }); + StepVerifier.create(mcpAsyncServer.addPrompt((McpServerFeatures.AsyncPromptRegistration) null)) + .verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null"); + }); } @Test void testAddPromptWithoutCapability() { // Create a server without prompt capabilities - McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport()) + McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .async(); + .build(); Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); - PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( - "Test prompt description", - List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + McpServerFeatures.AsyncPromptRegistration registration = new McpServerFeatures.AsyncPromptRegistration(prompt, + req -> Mono.just(new GetPromptResult("Test prompt description", List + .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))))); StepVerifier.create(serverWithoutPrompts.addPrompt(registration)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -315,9 +314,9 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testRemovePromptWithoutCapability() { // Create a server without prompt capabilities - McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport()) + McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .async(); + .build(); StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -330,15 +329,15 @@ public abstract class AbstractMcpAsyncServerTests { String TEST_PROMPT_NAME_TO_REMOVE = "TEST_PROMPT_NAME678"; Prompt prompt = new Prompt(TEST_PROMPT_NAME_TO_REMOVE, "Test Prompt", List.of()); - PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( - "Test prompt description", - List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + McpServerFeatures.AsyncPromptRegistration registration = new McpServerFeatures.AsyncPromptRegistration(prompt, + req -> Mono.just(new GetPromptResult("Test prompt description", List + .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))))); - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) .prompts(registration) - .async(); + .build(); StepVerifier.create(mcpAsyncServer.removePrompt(TEST_PROMPT_NAME_TO_REMOVE)).verifyComplete(); @@ -347,10 +346,10 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testRemoveNonexistentPrompt() { - var mcpAsyncServer2 = McpServer.using(createMcpTransport()) + var mcpAsyncServer2 = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) - .async(); + .build(); StepVerifier.create(mcpAsyncServer2.removePrompt("nonexistent-prompt")).verifyErrorSatisfies(error -> { assertThat(error).isInstanceOf(McpError.class) @@ -371,15 +370,15 @@ public abstract class AbstractMcpAsyncServerTests { var rootsReceived = new McpSchema.Root[1]; var consumerCalled = new boolean[1]; - var singleConsumerServer = McpServer.using(createMcpTransport()) + var singleConsumerServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .rootsChangeConsumers(List.of(roots -> { + .rootsChangeConsumers(List.of(roots -> Mono.fromRunnable(() -> { consumerCalled[0] = true; if (!roots.isEmpty()) { rootsReceived[0] = roots.get(0); } - })) - .async(); + }))) + .build(); assertThat(singleConsumerServer).isNotNull(); assertThatCode(() -> singleConsumerServer.closeGracefully().block(Duration.ofSeconds(10))) @@ -391,13 +390,13 @@ public abstract class AbstractMcpAsyncServerTests { var consumer2Called = new boolean[1]; var rootsContent = new List[1]; - var multipleConsumersServer = McpServer.using(createMcpTransport()) + var multipleConsumersServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") - .rootsChangeConsumers(List.of(roots -> { + .rootsChangeConsumers(List.of(roots -> Mono.fromRunnable(() -> { consumer1Called[0] = true; rootsContent[0] = roots; - }, roots -> consumer2Called[0] = true)) - .async(); + }), roots -> Mono.fromRunnable(() -> consumer2Called[0] = true))) + .build(); assertThat(multipleConsumersServer).isNotNull(); assertThatCode(() -> multipleConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) @@ -405,12 +404,12 @@ public abstract class AbstractMcpAsyncServerTests { onClose(); // Test error handling - var errorHandlingServer = McpServer.using(createMcpTransport()) + var errorHandlingServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .rootsChangeConsumers(List.of(roots -> { throw new RuntimeException("Test error"); })) - .async(); + .build(); assertThat(errorHandlingServer).isNotNull(); assertThatCode(() -> errorHandlingServer.closeGracefully().block(Duration.ofSeconds(10))) @@ -418,7 +417,7 @@ public abstract class AbstractMcpAsyncServerTests { onClose(); // Test without consumers - var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + var noConsumersServer = McpServer.async(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThat(noConsumersServer).isNotNull(); assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) @@ -431,10 +430,10 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testLoggingLevels() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) - .async(); + .build(); // Test all logging levels for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { @@ -450,10 +449,10 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testLoggingWithoutCapability() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) // No logging capability - .async(); + .build(); var notification = McpSchema.LoggingMessageNotification.builder() .level(McpSchema.LoggingLevel.INFO) @@ -466,10 +465,10 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testLoggingWithNullNotification() { - var mcpAsyncServer = McpServer.using(createMcpTransport()) + var mcpAsyncServer = McpServer.async(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) - .async(); + .build(); StepVerifier.create(mcpAsyncServer.loggingNotification(null)).verifyError(McpError.class); } diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java index 73dcc40..ddb827a 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java @@ -17,15 +17,11 @@ package org.springframework.ai.mcp.server; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.ai.mcp.server.McpServer.PromptRegistration; -import org.springframework.ai.mcp.server.McpServer.ResourceRegistration; -import org.springframework.ai.mcp.server.McpServer.ToolRegistration; import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; @@ -81,31 +77,31 @@ public abstract class AbstractMcpSyncServerTests { @Test void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> McpServer.sync(null)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) + assertThatThrownBy(() -> McpServer.sync(createMcpTransport()).serverInfo(null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Server info must not be null"); } @Test void testGracefulShutdown() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); } @Test void testImmediateClose() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException(); } @Test void testGetAsyncServer() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThat(mcpSyncServer.getAsyncServer()).isNotNull(); @@ -126,14 +122,14 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddTool() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .sync(); + .build(); Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema); assertThatCode(() -> mcpSyncServer - .addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false)))) + .addTool(new McpServerFeatures.SyncToolRegistration(newTool, args -> new CallToolResult(List.of(), false)))) .doesNotThrowAnyException(); assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); @@ -143,14 +139,14 @@ public abstract class AbstractMcpSyncServerTests { void testAddDuplicateTool() { Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tool(duplicateTool, args -> new CallToolResult(List.of(), false)) - .sync(); + .build(); - assertThatThrownBy(() -> mcpSyncServer - .addTool(new ToolRegistration(duplicateTool, args -> new CallToolResult(List.of(), false)))) + assertThatThrownBy(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolRegistration(duplicateTool, + args -> new CallToolResult(List.of(), false)))) .isInstanceOf(McpError.class) .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists"); @@ -161,11 +157,11 @@ public abstract class AbstractMcpSyncServerTests { void testRemoveTool() { Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", emptyJsonSchema); - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tool(tool, args -> new CallToolResult(List.of(), false)) - .sync(); + .build(); assertThatCode(() -> mcpSyncServer.removeTool(TEST_TOOL_NAME)).doesNotThrowAnyException(); @@ -174,10 +170,10 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemoveNonexistentTool() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) - .sync(); + .build(); assertThatThrownBy(() -> mcpSyncServer.removeTool("nonexistent-tool")).isInstanceOf(McpError.class) .hasMessage("Tool with name 'nonexistent-tool' not found"); @@ -187,7 +183,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testNotifyToolsListChanged() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.notifyToolsListChanged()).doesNotThrowAnyException(); @@ -200,7 +196,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testNotifyResourcesListChanged() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.notifyResourcesListChanged()).doesNotThrowAnyException(); @@ -209,15 +205,15 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddResource() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .sync(); + .build(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", null); - ResourceRegistration registration = new ResourceRegistration(resource, - req -> new ReadResourceResult(List.of())); + McpServerFeatures.SyncResourceRegistration registration = new McpServerFeatures.SyncResourceRegistration( + resource, req -> new ReadResourceResult(List.of())); assertThatCode(() -> mcpSyncServer.addResource(registration)).doesNotThrowAnyException(); @@ -226,12 +222,13 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddResourceWithNullRegistration() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .sync(); + .build(); - assertThatThrownBy(() -> mcpSyncServer.addResource(null)).isInstanceOf(McpError.class) + assertThatThrownBy(() -> mcpSyncServer.addResource((McpServerFeatures.SyncResourceRegistration) null)) + .isInstanceOf(McpError.class) .hasMessage("Resource must not be null"); assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); @@ -239,12 +236,12 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddResourceWithoutCapability() { - var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var serverWithoutResources = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", null); - ResourceRegistration registration = new ResourceRegistration(resource, - req -> new ReadResourceResult(List.of())); + McpServerFeatures.SyncResourceRegistration registration = new McpServerFeatures.SyncResourceRegistration( + resource, req -> new ReadResourceResult(List.of())); assertThatThrownBy(() -> serverWithoutResources.addResource(registration)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with resource capabilities"); @@ -252,7 +249,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemoveResourceWithoutCapability() { - var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var serverWithoutResources = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with resource capabilities"); @@ -264,7 +261,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testNotifyPromptsListChanged() { - var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException(); @@ -273,23 +270,24 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddPromptWithNullRegistration() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(false).build()) - .sync(); + .build(); - assertThatThrownBy(() -> mcpSyncServer.addPrompt(null)).isInstanceOf(McpError.class) + assertThatThrownBy(() -> mcpSyncServer.addPrompt((McpServerFeatures.SyncPromptRegistration) null)) + .isInstanceOf(McpError.class) .hasMessage("Prompt registration must not be null"); } @Test void testAddPromptWithoutCapability() { - var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var serverWithoutPrompts = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); - PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( - "Test prompt description", - List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + McpServerFeatures.SyncPromptRegistration registration = new McpServerFeatures.SyncPromptRegistration(prompt, + req -> new GetPromptResult("Test prompt description", List + .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(registration)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with prompt capabilities"); @@ -297,7 +295,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemovePromptWithoutCapability() { - var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var serverWithoutPrompts = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with prompt capabilities"); @@ -306,15 +304,15 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemovePrompt() { Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); - PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( - "Test prompt description", - List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + McpServerFeatures.SyncPromptRegistration registration = new McpServerFeatures.SyncPromptRegistration(prompt, + req -> new GetPromptResult("Test prompt description", List + .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) .prompts(registration) - .sync(); + .build(); assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException(); @@ -323,10 +321,10 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemoveNonexistentPrompt() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) - .sync(); + .build(); assertThatThrownBy(() -> mcpSyncServer.removePrompt("nonexistent-prompt")).isInstanceOf(McpError.class) .hasMessage("Prompt with name 'nonexistent-prompt' not found"); @@ -344,7 +342,7 @@ public abstract class AbstractMcpSyncServerTests { var rootsReceived = new McpSchema.Root[1]; var consumerCalled = new boolean[1]; - var singleConsumerServer = McpServer.using(createMcpTransport()) + var singleConsumerServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .rootsChangeConsumers(List.of(roots -> { consumerCalled[0] = true; @@ -352,7 +350,7 @@ public abstract class AbstractMcpSyncServerTests { rootsReceived[0] = roots.get(0); } })) - .sync(); + .build(); assertThat(singleConsumerServer).isNotNull(); assertThatCode(() -> singleConsumerServer.closeGracefully()).doesNotThrowAnyException(); @@ -363,32 +361,32 @@ public abstract class AbstractMcpSyncServerTests { var consumer2Called = new boolean[1]; var rootsContent = new List[1]; - var multipleConsumersServer = McpServer.using(createMcpTransport()) + var multipleConsumersServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .rootsChangeConsumers(List.of(roots -> { consumer1Called[0] = true; rootsContent[0] = roots; }, roots -> consumer2Called[0] = true)) - .sync(); + .build(); assertThat(multipleConsumersServer).isNotNull(); assertThatCode(() -> multipleConsumersServer.closeGracefully()).doesNotThrowAnyException(); onClose(); // Test error handling - var errorHandlingServer = McpServer.using(createMcpTransport()) + var errorHandlingServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .rootsChangeConsumers(List.of(roots -> { throw new RuntimeException("Test error"); })) - .sync(); + .build(); assertThat(errorHandlingServer).isNotNull(); assertThatCode(() -> errorHandlingServer.closeGracefully()).doesNotThrowAnyException(); onClose(); // Test without consumers - var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + var noConsumersServer = McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); assertThat(noConsumersServer).isNotNull(); assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException(); @@ -400,10 +398,10 @@ public abstract class AbstractMcpSyncServerTests { @Test void testLoggingLevels() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) - .sync(); + .build(); // Test all logging levels for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { @@ -419,10 +417,10 @@ public abstract class AbstractMcpSyncServerTests { @Test void testLoggingWithoutCapability() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) // No logging capability - .sync(); + .build(); var notification = McpSchema.LoggingMessageNotification.builder() .level(McpSchema.LoggingLevel.INFO) @@ -435,10 +433,10 @@ public abstract class AbstractMcpSyncServerTests { @Test void testLoggingWithNullNotification() { - var mcpSyncServer = McpServer.using(createMcpTransport()) + var mcpSyncServer = McpServer.sync(createMcpTransport()) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) - .sync(); + .build(); assertThatThrownBy(() -> mcpSyncServer.loggingNotification(null)).isInstanceOf(McpError.class); } diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransportIntegrationTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransportIntegrationTests.java index 71338a9..bc6bc06 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransportIntegrationTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/HttpServletSseServerTransportIntegrationTests.java @@ -36,7 +36,7 @@ import reactor.test.StepVerifier; import org.springframework.ai.mcp.client.McpClient; import org.springframework.ai.mcp.client.transport.HttpClientSseClientTransport; import org.springframework.ai.mcp.server.McpServer; -import org.springframework.ai.mcp.server.McpServer.ToolRegistration; +import org.springframework.ai.mcp.server.McpServerFeatures; import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; @@ -63,7 +63,7 @@ public class HttpServletSseServerTransportIntegrationTests { private HttpServletSseServerTransport mcpServerTransport; - McpClient.Builder clientBuilder; + McpClient.SyncSpec clientBuilder; private Tomcat tomcat; @@ -99,7 +99,7 @@ public class HttpServletSseServerTransportIntegrationTests { throw new RuntimeException("Failed to start Tomcat", e); } - this.clientBuilder = McpClient.using(new HttpClientSseClientTransport("http://localhost:" + PORT)); + this.clientBuilder = McpClient.sync(new HttpClientSseClientTransport("http://localhost:" + PORT)); } @AfterEach @@ -120,7 +120,7 @@ public class HttpServletSseServerTransportIntegrationTests { @Test void testCreateMessageWithoutInitialization() { - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + 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"))); @@ -137,9 +137,9 @@ public class HttpServletSseServerTransportIntegrationTests { @Test void testCreateMessageWithoutSamplingCapabilities() { - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).sync(); + var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build(); InitializeResult initResult = client.initialize(); assertThat(initResult).isNotNull(); @@ -159,7 +159,7 @@ public class HttpServletSseServerTransportIntegrationTests { @Test void testCreateMessageSuccess() { - var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.async(mcpServerTransport).serverInfo("test-server", "1.0.0").build(); Function samplingHandler = request -> { assertThat(request.messages()).hasSize(1); @@ -172,7 +172,7 @@ public class HttpServletSseServerTransportIntegrationTests { var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) - .sync(); + .build(); InitializeResult initResult = client.initialize(); assertThat(initResult).isNotNull(); @@ -199,13 +199,13 @@ public class HttpServletSseServerTransportIntegrationTests { List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); AtomicReference> rootsRef = new AtomicReference<>(); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) - .sync(); + .build(); var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) - .sync(); + .build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -235,8 +235,8 @@ public class HttpServletSseServerTransportIntegrationTests { @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", emptyJsonSchema), - request -> { + McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> { String response = RestClient.create() .get() .uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") @@ -246,12 +246,12 @@ public class HttpServletSseServerTransportIntegrationTests { return callResponse; }); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) - .sync(); + .build(); - var mcpClient = clientBuilder.sync(); + var mcpClient = clientBuilder.build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -270,8 +270,8 @@ public class HttpServletSseServerTransportIntegrationTests { @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", emptyJsonSchema), - request -> { + McpServerFeatures.SyncToolRegistration tool1 = new McpServerFeatures.SyncToolRegistration( + new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), request -> { String response = RestClient.create() .get() .uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") @@ -281,10 +281,10 @@ public class HttpServletSseServerTransportIntegrationTests { return callResponse; }); - var mcpServer = McpServer.using(mcpServerTransport) + var mcpServer = McpServer.sync(mcpServerTransport) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) - .sync(); + .build(); AtomicReference> toolsRef = new AtomicReference<>(); var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { @@ -295,7 +295,7 @@ public class HttpServletSseServerTransportIntegrationTests { .body(String.class); assertThat(response).isNotBlank(); toolsRef.set(toolsUpdate); - }).sync(); + }).build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -316,8 +316,8 @@ public class HttpServletSseServerTransportIntegrationTests { assertThat(toolsRef.get()).isEmpty(); }); - ToolRegistration tool2 = new ToolRegistration(new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), - request -> callResponse); + McpServerFeatures.SyncToolRegistration tool2 = new McpServerFeatures.SyncToolRegistration( + new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), request -> callResponse); mcpServer.addTool(tool2); @@ -331,8 +331,8 @@ public class HttpServletSseServerTransportIntegrationTests { @Test void testInitialize() { - var mcpServer = McpServer.using(mcpServerTransport).sync(); - var mcpClient = clientBuilder.sync(); + var mcpServer = McpServer.sync(mcpServerTransport).build(); + var mcpClient = clientBuilder.build(); InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); diff --git a/mcp/src/test/java/org/springframework/ai/mcp/spec/DefaultMcpSessionTests.java b/mcp/src/test/java/org/springframework/ai/mcp/spec/DefaultMcpSessionTests.java index acf7c44..9af6513 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/spec/DefaultMcpSessionTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/spec/DefaultMcpSessionTests.java @@ -150,7 +150,7 @@ class DefaultMcpSessionTests { @Test void testRequestHandling() { String echoMessage = "Hello MCP!"; - Map requestHandlers = Map.of(ECHO_METHOD, + Map> requestHandlers = Map.of(ECHO_METHOD, params -> Mono.just(params)); transport = new MockMcpTransport(); session = new DefaultMcpSession(TIMEOUT, transport, requestHandlers, Map.of()); diff --git a/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/ToolHelper.java b/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/ToolHelper.java index 943f988..a3c5e44 100644 --- a/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/ToolHelper.java +++ b/spring-ai-mcp/src/main/java/org/springframework/ai/mcp/spring/ToolHelper.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.mcp.server.McpServer; +import org.springframework.ai.mcp.server.McpServerFeatures; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.model.ModelOptionsUtils; import org.springframework.ai.model.function.FunctionCallback; @@ -60,14 +61,62 @@ public final class ToolHelper { private ToolHelper() { } + public static List toSyncToolRegistration( + List functionCallbacks) { + return functionCallbacks.stream().map(ToolHelper::toSyncToolRegistration).toList(); + } + + @Deprecated public static List toToolRegistration(List functionCallbacks) { return functionCallbacks.stream().map(ToolHelper::toToolRegistration).toList(); } + public static List toSyncToolRegistration( + FunctionCallback... functionCallbacks) { + return toSyncToolRegistration(List.of(functionCallbacks)); + } + + @Deprecated public static List toToolRegistration(FunctionCallback... functionCallbacks) { return toToolRegistration(List.of(functionCallbacks)); } + /** + * Converts a Spring AI FunctionCallback to an MCP SyncToolRegistration. This enables + * Spring AI functions to be exposed as MCP tools that can be discovered and invoked + * by language models. + * + *

    + * The conversion process: + *

      + *
    • Creates an MCP Tool with the function's name and input schema
    • + *
    • Wraps the function's execution in a SyncToolRegistration that handles the MCP + * protocol
    • + *
    • Provides error handling and result formatting according to MCP + * specifications
    • + *
    + * + * You can use the FunctionCallback builder to create a new instance of + * FunctionCallback using either java.util.function.Function or Method reference. + * @param functionCallback the Spring AI function callback to convert + * @return an MCP SyncToolRegistration that wraps the function callback + * @throws RuntimeException if there's an error during the function execution + */ + public static McpServerFeatures.SyncToolRegistration toSyncToolRegistration(FunctionCallback functionCallback) { + var tool = new McpSchema.Tool(functionCallback.getName(), functionCallback.getName(), + functionCallback.getInputTypeSchema()); + + return new McpServerFeatures.SyncToolRegistration(tool, request -> { + try { + String callResult = functionCallback.call(ModelOptionsUtils.toJsonString(request)); + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(callResult)), false); + } + catch (Exception e) { + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(e.getMessage())), true); + } + }); + } + /** * Converts a Spring AI FunctionCallback to an MCP ToolRegistration. This enables * Spring AI functions to be exposed as MCP tools that can be discovered and invoked @@ -88,7 +137,9 @@ public final class ToolHelper { * @param functionCallback the Spring AI function callback to convert * @return an MCP ToolRegistration that wraps the function callback * @throws RuntimeException if there's an error during the function execution + * @deprecated Use {@link #toSyncToolRegistration(FunctionCallback)}. */ + @Deprecated public static McpServer.ToolRegistration toToolRegistration(FunctionCallback functionCallback) { var tool = new McpSchema.Tool(functionCallback.getName(), functionCallback.getName(),