From 0fd93a9f82bef238bcd1045ce1338423483ac5fe Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Thu, 2 Jan 2025 08:27:12 +0100 Subject: [PATCH] feat: Add roots change notification support to MCP server - Implement roots change notification handler in McpAsyncServer - Add rootsChangeConsumer builder methods to support single and multiple consumers - Rename info() builder method to serverInfo() for clarity - Add comprehensive tests for roots change notification functionality - Add default server info implementation in Builder Resolves #39 --- .../ai/mcp/server/McpAsyncServer.java | 41 ++++++- .../ai/mcp/server/McpServer.java | 65 +++++++++-- .../ai/mcp/attic/DemoServer.java | 2 +- .../server/AbstractMcpAsyncServerTests.java | 108 ++++++++++++++---- .../server/AbstractMcpSyncServerTests.java | 106 +++++++++++++---- .../ai/mcp/sample/server/McpServerConfig.java | 2 +- 6 files changed, 266 insertions(+), 58 deletions(-) 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 d25003d..75a9879 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 @@ -23,6 +23,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; import com.fasterxml.jackson.core.type.TypeReference; import org.slf4j.Logger; @@ -39,6 +40,7 @@ import org.springframework.ai.mcp.spec.McpError; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities; +import org.springframework.ai.mcp.spec.McpSchema.ListRootsResult; import org.springframework.ai.mcp.spec.McpSchema.LoggingLevel; import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification; import org.springframework.ai.mcp.spec.McpSchema.Tool; @@ -84,7 +86,6 @@ public class McpAsyncServer { private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG; - // TODO: Add support for roots list changed notification /** * Create a new McpAsyncServer with the given transport and capabilities. * @param mcpTransport The transport layer implementation for MCP communication @@ -94,11 +95,13 @@ public class McpAsyncServer { * @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 */ public McpAsyncServer(McpTransport mcpTransport, McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, List resourceTemplates, - Map prompts) { + Map prompts, List>> rootsChangeConsumers) { this.serverInfo = serverInfo; this.tools = new CopyOnWriteArrayList<>(tools != null ? tools : List.of()); @@ -156,6 +159,13 @@ public class McpAsyncServer { notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (params) -> Mono.empty()); + if (Utils.isEmpty(rootsChangeConsumers)) { + rootsChangeConsumers = List.of((roots) -> logger + .warn("Roots list changed notification, but no consumers provided. Roots list changed: {}", roots)); + } + notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED, + rootsListChnagedNotificationHandler(rootsChangeConsumers)); + this.transport = mcpTransport; this.mcpSession = new DefaultMcpSession(Duration.ofSeconds(10), mcpTransport, requestHandlers, notificationHandlers); @@ -238,6 +248,33 @@ public class McpAsyncServer { this.mcpSession.close(); } + private static TypeReference LIST_ROOTS_RESULT_TYPE_REF = new TypeReference<>() { + }; + + private NotificationHandler rootsListChnagedNotificationHandler( + List>> rootsChangeConsumers) { + + if (this.clientCapabilities != null && this.clientCapabilities.roots() != null) { + + Mono updatedRootsList = this.mcpSession.sendRequest(McpSchema.METHOD_ROOTS_LIST, null, + LIST_ROOTS_RESULT_TYPE_REF); + + return params -> updatedRootsList // @formatter:off + .flatMap(listRootsResult -> + Mono.fromRunnable(() -> + rootsChangeConsumers.stream().forEach(consumer -> consumer.accept(listRootsResult.roots()))) + .subscribeOn(Schedulers.boundedElastic())) // TODO: Check if this is needed + .onErrorResume(error -> { + logger.error("Error handling roots list change notification", error); + return Mono.empty(); + }) + .then(); // Convert to Mono + // @formatter:on + } + + return params -> Mono.empty(); + }; + // --------------------------------------- // Tool Management // --------------------------------------- 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 a92879a..63588f4 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 @@ -20,6 +20,7 @@ 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 org.springframework.ai.mcp.spec.McpSchema; @@ -206,9 +207,12 @@ public interface McpServer { */ public static class Builder { + private final static McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server", + "1.0.0"); + private final McpTransport transport; - private McpSchema.Implementation serverInfo; + private McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO; private McpSchema.ServerCapabilities serverCapabilities; @@ -241,6 +245,8 @@ public interface McpServer { */ private Map prompts = new HashMap<>(); + private List>> rootsChangeConsumers = new ArrayList<>(); + private Builder(McpTransport transport) { Assert.notNull(transport, "Transport must not be null"); this.transport = transport; @@ -255,7 +261,7 @@ public interface McpServer { * @return This builder instance for method chaining * @throws IllegalArgumentException if serverInfo is null */ - public Builder info(McpSchema.Implementation serverInfo) { + public Builder serverInfo(McpSchema.Implementation serverInfo) { Assert.notNull(serverInfo, "Server info must not be null"); this.serverInfo = serverInfo; return this; @@ -263,14 +269,15 @@ public interface McpServer { /** * Sets the server implementation information using name and version strings. This - * is a convenience method alternative to {@link #info(McpSchema.Implementation)}. + * 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 #info(McpSchema.Implementation) + * @see #serverInfo(McpSchema.Implementation) */ - public Builder info(String name, String version) { + public Builder 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); @@ -518,6 +525,49 @@ public interface McpServer { 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 Builder 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 Builder 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 Builder rootsChangeConsumers(Consumer>... consumers) { + for (Consumer> consumer : consumers) { + this.rootsChangeConsumers.add(consumer); + } + return this; + } + /** * Builds a synchronous MCP server that provides blocking operations. Synchronous * servers process each request to completion before handling the next one, making @@ -539,12 +589,9 @@ public interface McpServer { * settings */ public McpAsyncServer async() { - if (serverInfo == null) { - serverInfo = new McpSchema.Implementation("mcp-server", "1.0.0"); - } return new McpAsyncServer(transport, serverInfo, serverCapabilities, tools, resources, resourceTemplates, - prompts); + prompts, rootsChangeConsumers); } } diff --git a/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java b/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java index a389b4b..d663f14 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java @@ -43,7 +43,7 @@ public class DemoServer { SseServerTransport transport = new SseServerTransport(new ObjectMapper(), "/mcp/message"); var mcpServer = McpServer.using(transport) - .info("Weather Forecast", "1.0.0") + .serverInfo("Weather Forecast", "1.0.0") .tool(new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")), (arguments) -> { String city = (String) arguments.get("city"); 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 6de166e..d6e7e26 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 @@ -84,21 +84,21 @@ public abstract class AbstractMcpAsyncServerTests { assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpServer.using(createMcpTransport()).info((McpSchema.Implementation) null)) + assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Server info must not be null"); } @Test void testGracefulShutdown() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete(); } @Test void testImmediateClose() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException(); } @@ -111,7 +111,7 @@ public abstract class AbstractMcpAsyncServerTests { void testAddTool() { Tool newTool = new McpSchema.Tool("new-tool", "New test tool", Map.of("input", "string")); var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .async(); @@ -127,7 +127,7 @@ public abstract class AbstractMcpAsyncServerTests { Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string")); var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tool(duplicateTool, args -> new CallToolResult(List.of(), false)) .async(); @@ -148,7 +148,7 @@ public abstract class AbstractMcpAsyncServerTests { Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string")); var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tool(too, args -> new CallToolResult(List.of(), false)) .async(); @@ -161,7 +161,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testRemoveNonexistentTool() { var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .async(); @@ -177,7 +177,7 @@ public abstract class AbstractMcpAsyncServerTests { Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string")); var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tool(too, args -> new CallToolResult(List.of(), false)) .async(); @@ -193,7 +193,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testNotifyResourcesListChanged() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete(); @@ -203,7 +203,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testAddResource() { var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) .async(); @@ -220,7 +220,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testAddResourceWithNullRegistration() { var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) .async(); @@ -235,7 +235,7 @@ public abstract class AbstractMcpAsyncServerTests { void testAddResourceWithoutCapability() { // Create a server without resource capabilities McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .async(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", @@ -253,7 +253,7 @@ public abstract class AbstractMcpAsyncServerTests { void testRemoveResourceWithoutCapability() { // Create a server without resource capabilities McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .async(); StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> { @@ -268,7 +268,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testNotifyPromptsListChanged() { - var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async(); + var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete(); @@ -278,7 +278,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testAddPromptWithNullRegistration() { var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(false).build()) .async(); @@ -291,7 +291,7 @@ public abstract class AbstractMcpAsyncServerTests { void testAddPromptWithoutCapability() { // Create a server without prompt capabilities McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .async(); Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); @@ -309,7 +309,7 @@ public abstract class AbstractMcpAsyncServerTests { void testRemovePromptWithoutCapability() { // Create a server without prompt capabilities McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .async(); StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> { @@ -328,7 +328,7 @@ public abstract class AbstractMcpAsyncServerTests { List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) .prompts(registration) .async(); @@ -341,7 +341,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testRemoveNonexistentPrompt() { var mcpAsyncServer2 = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) .async(); @@ -354,6 +354,70 @@ public abstract class AbstractMcpAsyncServerTests { .doesNotThrowAnyException(); } + // --------------------------------------- + // Roots Tests + // --------------------------------------- + + @Test + void testRootsChangeConsumers() { + // Test with single consumer + var rootsReceived = new McpSchema.Root[1]; + var consumerCalled = new boolean[1]; + + var singleConsumerServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + consumerCalled[0] = true; + if (!roots.isEmpty()) { + rootsReceived[0] = roots.get(0); + } + })) + .async(); + + assertThat(singleConsumerServer).isNotNull(); + assertThatCode(() -> singleConsumerServer.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + onClose(); + + // Test with multiple consumers + var consumer1Called = new boolean[1]; + var consumer2Called = new boolean[1]; + var rootsContent = new List[1]; + + var multipleConsumersServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + consumer1Called[0] = true; + rootsContent[0] = roots; + }, roots -> consumer2Called[0] = true)) + .async(); + + assertThat(multipleConsumersServer).isNotNull(); + assertThatCode(() -> multipleConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + onClose(); + + // Test error handling + var errorHandlingServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + throw new RuntimeException("Test error"); + })) + .async(); + + assertThat(errorHandlingServer).isNotNull(); + assertThatCode(() -> errorHandlingServer.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + onClose(); + + // Test without consumers + var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + + assertThat(noConsumersServer).isNotNull(); + assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + } + // --------------------------------------- // Logging Tests // --------------------------------------- @@ -361,7 +425,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testLoggingLevels() { var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) .async(); @@ -380,7 +444,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testLoggingWithoutCapability() { var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) // No logging capability .async(); @@ -396,7 +460,7 @@ public abstract class AbstractMcpAsyncServerTests { @Test void testLoggingWithNullNotification() { var mcpAsyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) .async(); 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 04fa9a3..def8527 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 @@ -83,28 +83,28 @@ public abstract class AbstractMcpSyncServerTests { assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class) .hasMessage("Transport must not be null"); - assertThatThrownBy(() -> McpServer.using(createMcpTransport()).info((McpSchema.Implementation) null)) + assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Server info must not be null"); } @Test void testGracefulShutdown() { - var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); } @Test void testImmediateClose() { - var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException(); } @Test void testGetAsyncServer() { - var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); assertThat(mcpSyncServer.getAsyncServer()).isNotNull(); @@ -118,7 +118,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddTool() { var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .sync(); @@ -135,7 +135,7 @@ public abstract class AbstractMcpSyncServerTests { Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string")); var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tool(duplicateTool, args -> new CallToolResult(List.of(), false)) .sync(); @@ -153,7 +153,7 @@ public abstract class AbstractMcpSyncServerTests { Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", Map.of("input", "string")); var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tool(tool, args -> new CallToolResult(List.of(), false)) .sync(); @@ -166,7 +166,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemoveNonexistentTool() { var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .sync(); @@ -178,7 +178,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testNotifyToolsListChanged() { - var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); assertThatCode(() -> mcpSyncServer.notifyToolsListChanged()).doesNotThrowAnyException(); @@ -191,7 +191,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testNotifyResourcesListChanged() { - var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); assertThatCode(() -> mcpSyncServer.notifyResourcesListChanged()).doesNotThrowAnyException(); @@ -201,7 +201,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddResource() { var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) .sync(); @@ -218,7 +218,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddResourceWithNullRegistration() { var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().resources(true, false).build()) .sync(); @@ -230,7 +230,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddResourceWithoutCapability() { - var serverWithoutResources = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", null); @@ -243,7 +243,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemoveResourceWithoutCapability() { - var serverWithoutResources = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with resource capabilities"); @@ -255,7 +255,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testNotifyPromptsListChanged() { - var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException(); @@ -265,7 +265,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddPromptWithNullRegistration() { var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(false).build()) .sync(); @@ -275,7 +275,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testAddPromptWithoutCapability() { - var serverWithoutPrompts = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( @@ -288,7 +288,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemovePromptWithoutCapability() { - var serverWithoutPrompts = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync(); + var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).isInstanceOf(McpError.class) .hasMessage("Server must be configured with prompt capabilities"); @@ -302,7 +302,7 @@ public abstract class AbstractMcpSyncServerTests { List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) .prompts(registration) .sync(); @@ -315,7 +315,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testRemoveNonexistentPrompt() { var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().prompts(true).build()) .sync(); @@ -325,6 +325,66 @@ public abstract class AbstractMcpSyncServerTests { assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); } + // --------------------------------------- + // Roots Tests + // --------------------------------------- + + @Test + void testRootsChangeConsumers() { + // Test with single consumer + var rootsReceived = new McpSchema.Root[1]; + var consumerCalled = new boolean[1]; + + var singleConsumerServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + consumerCalled[0] = true; + if (!roots.isEmpty()) { + rootsReceived[0] = roots.get(0); + } + })) + .sync(); + + assertThat(singleConsumerServer).isNotNull(); + assertThatCode(() -> singleConsumerServer.closeGracefully()).doesNotThrowAnyException(); + onClose(); + + // Test with multiple consumers + var consumer1Called = new boolean[1]; + var consumer2Called = new boolean[1]; + var rootsContent = new List[1]; + + var multipleConsumersServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + consumer1Called[0] = true; + rootsContent[0] = roots; + }, roots -> consumer2Called[0] = true)) + .sync(); + + assertThat(multipleConsumersServer).isNotNull(); + assertThatCode(() -> multipleConsumersServer.closeGracefully()).doesNotThrowAnyException(); + onClose(); + + // Test error handling + var errorHandlingServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + throw new RuntimeException("Test error"); + })) + .sync(); + + assertThat(errorHandlingServer).isNotNull(); + assertThatCode(() -> errorHandlingServer.closeGracefully()).doesNotThrowAnyException(); + onClose(); + + // Test without consumers + var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThat(noConsumersServer).isNotNull(); + assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException(); + } + // --------------------------------------- // Logging Tests // --------------------------------------- @@ -332,7 +392,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testLoggingLevels() { var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) .sync(); @@ -351,7 +411,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testLoggingWithoutCapability() { var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) // No logging capability .sync(); @@ -367,7 +427,7 @@ public abstract class AbstractMcpSyncServerTests { @Test void testLoggingWithNullNotification() { var mcpSyncServer = McpServer.using(createMcpTransport()) - .info("test-server", "1.0.0") + .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().logging().build()) .sync(); diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java index 8d919b8..d0b9e44 100644 --- a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java +++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java @@ -67,7 +67,7 @@ public class McpServerConfig { // Create the server with both tool and resource capabilities var server = McpServer.using(transport) - .info("MCP Demo Server", "1.0.0") + .serverInfo("MCP Demo Server", "1.0.0") .capabilities(capabilities) .resources(systemInfoResourceRegistration()) .prompts(greetingPromptRegistration())