feat(core): add roots list support and improve documentation

- Add roots list capability to allow servers to understand filesystem boundaries
- Move architecture diagram and add detailed documentation for transports and sessions
- Enhance client capabilities with roots and sampling features
- Rename withRequestTimeout to requestTimeout for better API consistency
- Add tests for roots list functionality

Resolves #8
This commit is contained in:
Christian Tzolov 2024-12-19 12:09:10 +01:00 • committed by Christian Tzolov
commit 82c64f5ad3
12 changed files with 810 additions and 45 deletions

View file

@ -15,6 +15,7 @@ This SDK implements the Model Context Protocol, enabling seamless integration wi
- Tool discovery and execution
- Resource management with URI templates
- Resource subscription system
- Roots list management and notifications
- Prompt handling and management
- Server initialization and ping
- Multiple transport implementations:
@ -72,6 +73,43 @@ The SSE transport provides:
- Graceful shutdown handling
- Configurable JSON serialization
### Roots List Support
The SDK supports the MCP roots list capability, which allows servers to understand which directories and files they have access to. Clients can provide a list of root directories/files and notify servers when this list changes.
#### Features
- Define root providers that supply filesystem access boundaries
- Support for roots list changed notifications
- Automatic roots list request handling
#### Example with Roots List Configuration
```java
// Create root providers
List<Supplier<List<Root>>> rootProviders = List.of(
() -> List.of(new Root("file:///workspace/project", "Project Root")),
() -> List.of(new Root("file:///workspace/docs", "Documentation"))
);
// Create async client with roots list support
McpAsyncClient client = new McpAsyncClient(
new StdioClientTransport(params),
Duration.ofSeconds(30),
rootProviders, // Configure root providers
true // Enable roots list changed notifications
);
// Initialize connection
client.initialize()
.doOnSuccess(result -> {
// Connection initialized with roots list capability
// Notify server when roots list changes
return client.sendRootsListChanged();
})
.subscribe();
```
### Sync Client Example
```java
@ -171,7 +209,7 @@ The SDK follows a layered architecture with clear separation of concerns:
- **StdioClientTransport**: Standard I/O based client to server communication
- **SseClientTransport**: HTTP-based transport using Server-Sent Events for bidirectional client-sever communication
<img src="docs/spring-ai-mcp-uml-classdiagram.svg" width="600"/>
<img src="../docs/spring-ai-mcp-uml-classdiagram.svg" width="600"/>
### Key Interactions

View file

@ -16,19 +16,27 @@
package org.springframework.ai.mcp.client;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import com.fasterxml.jackson.core.type.TypeReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import org.springframework.ai.mcp.spec.DefaultMcpSession;
import org.springframework.ai.mcp.spec.DefaultMcpSession.RequestHandler;
import org.springframework.ai.mcp.spec.McpError;
import org.springframework.ai.mcp.spec.McpSchema;
import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
import org.springframework.ai.mcp.spec.McpSchema.PaginatedRequest;
import org.springframework.ai.mcp.spec.McpSchema.Root;
import org.springframework.ai.mcp.spec.McpTransport;
/**
@ -52,23 +60,62 @@ public class McpAsyncClient {
private final DefaultMcpSession mcpSession;
/**
* Create a new McpAsyncClient with the given transport.
* @param transport the transport to use.
* Roots define the boundaries of where servers can operate within the filesystem,
* allowing them to understand which directories and files they have access to.
* Servers can request the list of roots from supporting clients and receive
* notifications when that list changes.
*/
public McpAsyncClient(McpTransport transport) {
this(transport, Duration.ofSeconds(20));
}
private McpSchema.ClientCapabilities.RootCapabilities rootCapabilities;
/**
* 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 rootsListProviders the list of suppliers that provide the list of roots
* backing the roots list request.
* @param rootsListChangedNotification whether the client supports roots/list_changed
* notification.
*/
public McpAsyncClient(McpTransport transport, Duration requestTimeout) {
this.mcpSession = new DefaultMcpSession(requestTimeout, transport);
public McpAsyncClient(McpTransport transport, Duration requestTimeout,
List<Supplier<List<Root>>> rootsListProviders, boolean rootsListChangedNotification) {
Map<String, RequestHandler> requestHanlers = new HashMap<>();
if (rootsListProviders != null && !rootsListProviders.isEmpty()) {
requestHanlers.put("roots/list", rootsListRequestHandler(rootsListProviders));
this.rootCapabilities = new McpSchema.ClientCapabilities.RootCapabilities(rootsListChangedNotification);
}
this.mcpSession = new DefaultMcpSession(requestTimeout, transport, requestHanlers, Map.of());
}
private RequestHandler rootsListRequestHandler(List<Supplier<List<Root>>> rootsListProviders) {
return new RequestHandler() {
@Override
public Mono<Object> handle(Object params) {
if (rootsListProviders == null || rootsListProviders.isEmpty()) {
return Mono.just(new ArrayList<Root>());
}
List<Mono<List<Root>>> monos = rootsListProviders.stream()
.map(supplier -> Mono.fromSupplier(supplier).subscribeOn(Schedulers.boundedElastic()))
.toList();
return Mono.zip(monos, arrays -> {
List<Root> combinedList = new ArrayList<>();
for (Object array : arrays) {
@SuppressWarnings("unchecked")
List<Root> roots = (List<Root>) array;
combinedList.addAll(roots);
}
return combinedList;
});
}
};
};
/**
* The initialization phase MUST be the first interaction between client and server.
* During this phase, the client and server:
@ -100,8 +147,8 @@ public class McpAsyncClient {
public Mono<McpSchema.InitializeResult> initialize() {
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
McpSchema.LATEST_PROTOCOL_VERSION,
new McpSchema.ClientCapabilities(null, new McpSchema.ClientCapabilities.RootCapabilities(true), null),
new McpSchema.Implementation("mcp-java-client", "0.0.1")); // @formatter:on
new McpSchema.ClientCapabilities(null, rootCapabilities, null),
new McpSchema.Implementation("mcp-java-client", "0.2.0")); // @formatter:on
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest("initialize", initializeRequest,
new TypeReference<McpSchema.InitializeResult>() {

View file

@ -17,7 +17,11 @@
package org.springframework.ai.mcp.client;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import org.springframework.ai.mcp.spec.McpSchema.Root;
import org.springframework.ai.mcp.spec.McpTransport;
import org.springframework.ai.mcp.util.Assert;
@ -38,10 +42,12 @@ import org.springframework.ai.mcp.util.Assert;
* including request timeout and JSON object mapping.
*
* <p>
* Use the builder pattern for flexible client configuration: <pre>{@code
* Use the builder pattern for flexible client configuration:
*
* <pre>{@code
* McpClient.using(transport)
* .withRequestTimeout(Duration.ofSeconds(5))
* .sync(); // or .async()
* .withRequestTimeout(Duration.ofSeconds(5))
* .sync(); // or .async()
* }</pre>
*
* @author Christian Tzolov
@ -74,6 +80,10 @@ public class McpClient {
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
private boolean rootsListChangedNotification = false;
private List<Supplier<List<Root>>> rootsListProviders = new ArrayList<>();
private Builder(McpTransport transport) {
Assert.notNull(transport, "Transport must not be null");
this.transport = transport;
@ -84,12 +94,22 @@ public class McpClient {
* @param requestTimeout The duration to wait before timing out requests
* @return This builder instance
*/
public Builder withRequestTimeout(Duration requestTimeout) {
public Builder requestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
this.requestTimeout = requestTimeout;
return this;
}
public Builder rootsListChangedNotification(boolean rootsListChangedNotification) {
this.rootsListChangedNotification = rootsListChangedNotification;
return this;
}
public Builder rootsListProvider(Supplier<List<Root>> rootsListProvider) {
this.rootsListProviders.add(rootsListProvider);
return this;
}
/**
* Build a synchronous MCP client.
* @return A new instance of {@link McpSyncClient}
@ -103,7 +123,7 @@ public class McpClient {
* @return A new instance of {@link McpAsyncClient}
*/
public McpAsyncClient async() {
return new McpAsyncClient(transport, requestTimeout);
return new McpAsyncClient(transport, requestTimeout, rootsListProviders, rootsListChangedNotification);
}
}
@ -121,7 +141,7 @@ public class McpClient {
*/
@Deprecated
public static McpAsyncClient async(McpTransport transport, Duration requestTimeout) {
return using(transport).withRequestTimeout(requestTimeout).async();
return using(transport).requestTimeout(requestTimeout).async();
}
/**
@ -137,7 +157,7 @@ public class McpClient {
*/
@Deprecated
public static McpSyncClient sync(McpTransport transport, Duration requestTimeout) {
return using(transport).withRequestTimeout(requestTimeout).sync();
return using(transport).requestTimeout(requestTimeout).sync();
}
}

View file

@ -108,15 +108,6 @@ public class DefaultMcpSession implements McpSession {
}
/**
* Creates a new DefaultMcpSession with the specified configuration.
* @param requestTimeout Duration to wait for responses
* @param transport Transport implementation for message exchange
*/
public DefaultMcpSession(Duration requestTimeout, McpTransport transport) {
this(requestTimeout, transport, Map.of(), Map.of());
}
/**
* Creates a new DefaultMcpSession with the specified configuration and handlers.
* @param requestTimeout Duration to wait for responses

View file

@ -155,16 +155,53 @@ public class McpSchema {
@JsonProperty("instructions") String instructions) {
} // @formatter:on
/**
* Clients can implement additional features to enrich connected MCP servers with
* additional capabilities. These capabilities can be used to extend the functionality
* of the server, or to provide additional information to the server about the
* client's capabilities.
*
* @param experimental WIP
* @param roots define the boundaries of where servers can operate within the
* filesystem, allowing them to understand which directories and files they have
* access to.
* @param sampling Provides a standardized way for servers to request LLM sampling
* (“completions” or “generations”) from language models via clients.
*
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record ClientCapabilities( // @formatter:off
@JsonProperty("experimental") Map<String, Object> experimental,
@JsonProperty("roots") RootCapabilities roots,
@JsonProperty("sampling") Object sampling) {
@JsonProperty("sampling") Sampling sampling) {
/**
* Roots define the boundaries of where servers can operate within the filesystem,
* allowing them to understand which directories and files they have access to.
* Servers can request the list of roots from supporting clients and
* receive notifications when that list changes.
*
* @param listChanged Whether the client would send notification about roots
* has changed since the last time the server checked.
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record RootCapabilities(
@JsonProperty("listChanged") Boolean listChanged) {
}
/**
* 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 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.
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record Sampling() {
}
}// @formatter:on
@JsonInclude(JsonInclude.Include.NON_ABSENT)

View file

@ -39,7 +39,7 @@ public class ClientSessionTests2 {
.build();
try (McpSyncClient clientSession = McpClient.using(new StdioClientTransport(stdioParams))
.withRequestTimeout(Duration.ofSeconds(10))
.requestTimeout(Duration.ofSeconds(10))
.sync()) {
clientSession.initialize();

View file

@ -42,7 +42,7 @@ public class ClientSessionTests3 {
try {
clientSession = McpClient.using(new StdioClientTransport(stdioParams))
.withRequestTimeout(Duration.ofSeconds(10))
.requestTimeout(Duration.ofSeconds(10))
.sync();
clientSession.initialize();

View file

@ -40,7 +40,7 @@ public class MainSSE {
try (McpSyncClient mcpClient = McpClient
.using(new SseClientTransport(WebClient.builder().baseUrl("http://localhost:3001")))
.withRequestTimeout(Duration.ofSeconds(1000))
.requestTimeout(Duration.ofSeconds(1000))
.sync()) {
mcpClient.initialize();

View file

@ -17,7 +17,9 @@
package org.springframework.ai.mcp.client;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@ -28,6 +30,7 @@ import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
import org.springframework.ai.mcp.spec.McpSchema.Prompt;
import org.springframework.ai.mcp.spec.McpSchema.Resource;
import org.springframework.ai.mcp.spec.McpSchema.Root;
import org.springframework.ai.mcp.spec.McpSchema.Tool;
import org.springframework.ai.mcp.spec.McpTransport;
@ -64,7 +67,7 @@ public abstract class AbstractMcpAsyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
mcpAsyncClient = McpClient.using(mcpTransport).withRequestTimeout(TIMEOUT).async();
mcpAsyncClient = McpClient.using(mcpTransport).requestTimeout(TIMEOUT).async();
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
}).doesNotThrowAnyException();
}
@ -83,7 +86,7 @@ public abstract class AbstractMcpAsyncClientTests {
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpClient.using(mcpTransport).withRequestTimeout(null).sync())
assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Request timeout must not be null");
}
@ -123,11 +126,6 @@ public abstract class AbstractMcpAsyncClientTests {
assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class);
}
@Test
void testRootsListChanged() {
assertThatCode(() -> mcpAsyncClient.sendRootsListChanged().block()).doesNotThrowAnyException();
}
@Test
void testListResources() {
StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> {
@ -175,4 +173,21 @@ public abstract class AbstractMcpAsyncClientTests {
.verifyComplete();
}
@Test
void testRootsListChanged() {
assertThatCode(() -> mcpAsyncClient.sendRootsListChanged().block()).doesNotThrowAnyException();
}
@Test
void testInitializeWithRootsListProviders() {
var transport = createMcpTransport();
List<Supplier<List<Root>>> providers = List.of(() -> List.of(new Root("file:///test/path", "test-root")));
var client = new McpAsyncClient(transport, TIMEOUT, providers, true);
assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
assertThatCode(() -> client.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException();
}
}

View file

@ -64,7 +64,7 @@ public abstract class AbstractMcpSyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
mcpSyncClient = McpClient.using(mcpTransport).withRequestTimeout(TIMEOUT).sync();
mcpSyncClient = McpClient.using(mcpTransport).requestTimeout(TIMEOUT).sync();
mcpSyncClient.initialize();
}).doesNotThrowAnyException();
}
@ -82,7 +82,7 @@ public abstract class AbstractMcpSyncClientTests {
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Transport must not be null");
assertThatThrownBy(() -> McpClient.using(mcpTransport).withRequestTimeout(null).sync())
assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Request timeout must not be null");
}

View file

@ -0,0 +1,140 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.mcp.client;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.function.Supplier;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
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.McpSchema.Root;
import org.springframework.ai.mcp.spec.McpTransport;
import static org.assertj.core.api.Assertions.assertThat;
class McpAsyncClientResponseHandlerTests {
private McpAsyncClient asyncMcpClient;
private MockMcpTransport transport;
@SuppressWarnings("unused")
private static class MockMcpTransport implements McpTransport {
private final AtomicInteger inboundMessageCount = new AtomicInteger(0);
private Sinks.Many<McpSchema.JSONRPCMessage> outgoing = Sinks.many().multicast().onBackpressureBuffer();
private Sinks.Many<McpSchema.JSONRPCMessage> inbound = Sinks.many().unicast().onBackpressureBuffer();
private Flux<McpSchema.JSONRPCMessage> outboundView = outgoing.asFlux().cache(1);
public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
if (inbound.tryEmitNext(message).isFailure()) {
throw new RuntimeException("Failed to emit message " + message);
}
inboundMessageCount.incrementAndGet();
}
@Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
if (outgoing.tryEmitNext(message).isFailure()) {
return Mono.error(new RuntimeException("Can't emit outgoing message " + message));
}
return Mono.empty();
}
public McpSchema.JSONRPCRequest getLastSentMessageAsRequest() {
return (JSONRPCRequest) outboundView.blockFirst();
}
public McpSchema.JSONRPCNotification getLastSentMessageAsNotifiation() {
return (JSONRPCNotification) outboundView.blockFirst();
}
public McpSchema.JSONRPCMessage getLastSentMessage() {
return outboundView.blockFirst();
}
@Override
public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
return inbound.asFlux()
.publishOn(Schedulers.boundedElastic())
.flatMap(message -> Mono.just(message).transform(handler))
.then();
}
@Override
public Mono<Void> closeGracefully() {
return Mono.empty();
}
@Override
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
return new ObjectMapper().convertValue(data, typeRef);
}
}
@BeforeEach
void setUp() {
transport = new MockMcpTransport();
Supplier<List<Root>> rootsListProvider = () -> List.of(new Root("file:///test/path", "test-root"));
asyncMcpClient = McpClient.using(transport).rootsListProvider(rootsListProvider).async();
}
@AfterEach
void tearDown() {
if (asyncMcpClient != null) {
asyncMcpClient.closeGracefully();
}
}
@Test
void testRootsListRequestHandling() {
// Simulate incoming request
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "roots/list",
"test-id", null);
transport.simulateIncomingMessage(request);
// Verify response
McpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessage();
assertThat(sentMessage).isInstanceOf(McpSchema.JSONRPCResponse.class);
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.error()).isNull();
}
}

View file

@ -62,8 +62,6 @@ class DefaultMcpSessionTests {
private MockMcpTransport transport;
private ObjectMapper objectMapper;
@SuppressWarnings("unused")
private static class MockMcpTransport implements McpTransport {
@ -124,7 +122,6 @@ class DefaultMcpSessionTests {
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
transport = new MockMcpTransport();
session = new DefaultMcpSession(TIMEOUT, transport, Map.of(),
Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: " + params))));
@ -139,10 +136,12 @@ class DefaultMcpSessionTests {
@Test
void testConstructorWithInvalidArguments() {
assertThatThrownBy(() -> new DefaultMcpSession(null, transport)).isInstanceOf(IllegalArgumentException.class)
assertThatThrownBy(() -> new DefaultMcpSession(null, transport, Map.of(), Map.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("requstTimeout can not be null");
assertThatThrownBy(() -> new DefaultMcpSession(TIMEOUT, null)).isInstanceOf(IllegalArgumentException.class)
assertThatThrownBy(() -> new DefaultMcpSession(TIMEOUT, null, Map.of(), Map.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("transport can not be null");
}