diff --git a/mcp-docs/src/main/antora/modules/ROOT/images/MCP-layers.svg b/mcp-docs/src/main/antora/modules/ROOT/images/MCP-layers.svg index 2f38d6a..3847eaa 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/images/MCP-layers.svg +++ b/mcp-docs/src/main/antora/modules/ROOT/images/MCP-layers.svg @@ -1,24 +1,24 @@ + id="defs27" /> + inkscape:current-layer="svg27"> + + + id="g27" + transform="translate(-334.47681,-179.33106)"> - + + - - + + 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 dc3755b..de98d5d 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc @@ -31,13 +31,13 @@ Add the following dependency to your Maven project: == Architecture +image::mcp-layers.svg[width=400,float=right] + The SDK follows a layered architecture with clear separation of concerns: -image::MCP-layers.svg[width=400,align=center] - -* *Transport Layer (McpTransport)*: Handles JSON-RPC message serialization/deserialization via StdioTransport (stdin/stdout) and SseTransport (HTTP streaming). -* *Session Layer (McpSession)*: Manages communication patterns and state using DefaultMcpSession implementation. * *Client/Server Layer*: Both use McpSession for sync/async operations, with McpClient handling client-side protocol operations and McpServer managing server-side protocol operations. +* *Session Layer (McpSession)*: Manages communication patterns and state using DefaultMcpSession implementation. +* *Transport Layer (McpTransport)*: Handles JSON-RPC message serialization/deserialization via StdioTransport (stdin/stdout) and SseTransport (HTTP streaming). Following class diagram illustrates the layered architecture of the MCP SDK, showing the relationships between core interfaces (McpTransport, McpSession), their implementations, and the client/server components. It highlights how the transport layer connects to sessions, which in turn support both synchronous and asynchronous client/server implementations. 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 e1bc8f1..ae9141f 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 @@ -409,7 +409,7 @@ public class McpAsyncClient { List roots = this.roots.values().stream().toList(); - return Mono.just(roots); + return Mono.just(new McpSchema.ListRootsResult(roots)); }; } 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 ca8cb83..ee6882d 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 @@ -40,7 +40,6 @@ 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; @@ -251,28 +250,35 @@ public class McpAsyncServer { private static TypeReference LIST_ROOTS_RESULT_TYPE_REF = new TypeReference<>() { }; + /** + * Retrieves the list of all roots provided by the client. + * @return A Mono that emits the list of roots result. + */ + public Mono listRoots() { + return this.listRoots(null); + } + + /** + * Retrieves a paginated list of roots provided by the server. + * @param cursor Optional pagination cursor from a previous list request + * @return A Mono that emits the list of roots result containing + */ + public Mono listRoots(String cursor) { + return this.mcpSession.sendRequest(McpSchema.METHOD_ROOTS_LIST, new McpSchema.PaginatedRequest(cursor), + LIST_ROOTS_RESULT_TYPE_REF); + } + 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(); + return params -> { + return listRoots().flatMap(listRootsResult -> Mono.fromRunnable(() -> { + rootsChangeConsumers.stream().forEach(consumer -> consumer.accept(listRootsResult.roots())); + }).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> { + logger.error("Error handling roots list change notification", error); + return Mono.empty(); + }).then(); + }; }; // --------------------------------------- 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 6096d9e..db564f0 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 @@ -19,6 +19,7 @@ package org.springframework.ai.mcp.server; 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.ClientCapabilities; import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification; @@ -47,6 +48,23 @@ public class McpSyncServer { this.asyncServer = asyncServer; } + /** + * Retrieves the list of all roots provided by the client. + * @return The list of roots + */ + public McpSchema.ListRootsResult listRoots() { + return this.listRoots(null); + } + + /** + * Retrieves a paginated list of roots provided by the server. + * @param cursor Optional pagination cursor from a previous list request + * @return The list of roots + */ + public McpSchema.ListRootsResult listRoots(String cursor) { + return this.asyncServer.listRoots(cursor).block(); + } + /** * Add a new tool handler. * @param toolHandler The tool handler to add diff --git a/mcp/src/test/java/org/springframework/ai/mcp/MockMcpTransport.java b/mcp/src/test/java/org/springframework/ai/mcp/MockMcpTransport.java index 034a08f..20736b1 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/MockMcpTransport.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/MockMcpTransport.java @@ -30,9 +30,10 @@ import org.springframework.ai.mcp.spec.ClientMcpTransport; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.JSONRPCNotification; import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest; +import org.springframework.ai.mcp.spec.ServerMcpTransport; @SuppressWarnings("unused") -public class MockMcpTransport implements ClientMcpTransport { +public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport { private final AtomicInteger inboundMessageCount = new AtomicInteger(0); 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 da18e5f..81e0fd2 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 @@ -100,7 +100,8 @@ class McpAsyncClientResponseHandlerTests { McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage; assertThat(response.id()).isEqualTo("test-id"); - assertThat(response.result()).isEqualTo(List.of(new Root("file:///test/path", "test-root"))); + assertThat(response.result()) + .isEqualTo(new McpSchema.ListRootsResult(List.of(new Root("file:///test/path", "test-root")))); assertThat(response.error()).isNull(); asyncMcpClient.closeGracefully(); diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java index 65c7661..8e5caa5 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java @@ -15,8 +15,10 @@ */ package org.springframework.ai.mcp.server; +import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import com.fasterxml.jackson.databind.ObjectMapper; @@ -37,12 +39,14 @@ import org.springframework.ai.mcp.spec.McpSchema.CreateMessageRequest; import org.springframework.ai.mcp.spec.McpSchema.CreateMessageResult; import org.springframework.ai.mcp.spec.McpSchema.InitializeResult; import org.springframework.ai.mcp.spec.McpSchema.Role; +import org.springframework.ai.mcp.spec.McpSchema.Root; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.server.RouterFunctions; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; public class SseAsyncIntegrationTests { @@ -156,4 +160,60 @@ public class SseAsyncIntegrationTests { }).verifyComplete(); } + // --------------------------------------- + // Roots Tests + // --------------------------------------- + @Test + void testRootsSuccess() { + + List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); + + AtomicReference> rootsRef = new AtomicReference<>(); + var mcpServer = McpServer.using(mcpServerTransport) + .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + .sync(); + + // HttpHandler httpHandler = + // RouterFunctions.toHttpHandler(mcpServerTransport.getRouterFunction()); + // ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); + // HttpServer httpServer = HttpServer.create().port(8080).handle(adapter); + // DisposableServer d = httpServer.bindNow(); + + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + .roots(roots) + .sync(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(rootsRef.get()).isNull(); + + assertThat(mcpServer.listRoots().roots()).containsAll(roots); + + mcpClient.rootsListChangedNotification(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(roots); + }); + + // Remove a root + mcpClient.removeRoot(roots.get(0).uri()); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(roots.get(1))); + }); + + // Add a new root + var root3 = new Root("uri3://", "root3"); + mcpClient.addRoot(root3); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3)); + }); + + mcpClient.close(); + + mcpServer.close(); + } + }