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:
parent
7a98a935af
commit
82c64f5ad3
12 changed files with 810 additions and 45 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue