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
19 changed files with 810 additions and 45 deletions
|
|
@ -6,7 +6,7 @@ Java SDK for the Model Context Protocol (MCP), providing seamless integration be
|
||||||
|
|
||||||
Spring AI MCP is an experimental project that provides Java and Spring Framework integration for the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture). It enables Java applications to interact with AI models and tools through a standardized interface, supporting both synchronous and asynchronous communication patterns.
|
Spring AI MCP is an experimental project that provides Java and Spring Framework integration for the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture). It enables Java applications to interact with AI models and tools through a standardized interface, supporting both synchronous and asynchronous communication patterns.
|
||||||
|
|
||||||
<img src="spring-ai-mcp-architecture.jpg" width="600">
|
<img src="docs/spring-ai-mcp-architecture.jpg" width="600">
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
|
|
|
||||||
144
docs/CLIENT.SSE.TRANSPORT.md
Normal file
144
docs/CLIENT.SSE.TRANSPORT.md
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
# SSE Server Transport Implementation for MCP
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `SseClientTransport` class implements the Model Context Protocol (MCP) HTTP with SSE transport specification, providing a bidirectional communication channel between clients and servers. This implementation is part of the Spring AI MCP Core library and follows the [MCP HTTP with SSE Transport Specification](https://spec.modelcontextprotocol.io/specification/basic/transports/#http-with-sse).
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Bidirectional Communication**:
|
||||||
|
- Inbound: Receives messages through SSE connection
|
||||||
|
- Outbound: Sends messages via HTTP POST requests
|
||||||
|
|
||||||
|
- **Error Handling**:
|
||||||
|
- Automatic reconnection for transient failures
|
||||||
|
- Graceful shutdown capabilities
|
||||||
|
- Comprehensive error reporting
|
||||||
|
|
||||||
|
- **Message Flow**:
|
||||||
|
1. Client establishes SSE connection to `/sse` endpoint
|
||||||
|
2. Server sends 'endpoint' event with message URI
|
||||||
|
3. Client uses provided URI for outbound messages
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Transport Architecture
|
||||||
|
|
||||||
|
The transport consists of two main processing pipelines:
|
||||||
|
|
||||||
|
1. **Inbound Processing**:
|
||||||
|
- Establishes SSE connection to `/sse` endpoint
|
||||||
|
- Handles two types of events:
|
||||||
|
- `message`: JSON-RPC protocol messages
|
||||||
|
- `endpoint`: Server-provided URI for outbound messages
|
||||||
|
- Implements automatic retry logic for connection failures
|
||||||
|
|
||||||
|
2. **Outbound Processing**:
|
||||||
|
- Sends messages via HTTP POST to server-provided endpoint
|
||||||
|
- Uses dedicated scheduler for sequential message processing
|
||||||
|
- Handles JSON serialization and HTTP communication
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
- **WebClient**: Handles HTTP communications for both SSE and POST requests
|
||||||
|
- **ObjectMapper**: Manages JSON serialization/deserialization
|
||||||
|
- **Scheduler**: Ensures sequential outbound message processing
|
||||||
|
- **Message Endpoint Sink**: Manages server-provided endpoint URI
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Create WebClient builder with base URL
|
||||||
|
WebClient.Builder webClientBuilder = WebClient.builder()
|
||||||
|
.baseUrl("http://localhost:3001");
|
||||||
|
|
||||||
|
// Initialize transport
|
||||||
|
SseClientTransport transport = new SseClientTransport(webClientBuilder);
|
||||||
|
|
||||||
|
// Create a JSON-RPC request
|
||||||
|
JSONRPCRequest request = new JSONRPCRequest(
|
||||||
|
McpSchema.JSONRPC_VERSION,
|
||||||
|
"method-name",
|
||||||
|
"request-id",
|
||||||
|
Map.of("key", "value")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
transport.sendMessage(request)
|
||||||
|
.subscribe(
|
||||||
|
success -> System.out.println("Message sent successfully"),
|
||||||
|
error -> System.err.println("Error sending message: " + error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Graceful shutdown when done
|
||||||
|
transport.closeGracefully()
|
||||||
|
.subscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Message Processing
|
||||||
|
|
||||||
|
1. **Inbound Messages**:
|
||||||
|
- Received through SSE connection
|
||||||
|
- Automatically deserialized from JSON
|
||||||
|
- Routed to appropriate handlers based on event type
|
||||||
|
|
||||||
|
2. **Outbound Messages**:
|
||||||
|
- Serialized to JSON
|
||||||
|
- Sent via HTTP POST
|
||||||
|
- Processed sequentially on dedicated thread
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **Connection Failures**:
|
||||||
|
- Automatic retry for transient failures
|
||||||
|
- Configurable retry policies
|
||||||
|
- Graceful handling of permanent failures
|
||||||
|
|
||||||
|
- **Message Processing Errors**:
|
||||||
|
- Comprehensive error reporting
|
||||||
|
- Clean failure handling
|
||||||
|
- Maintains system stability
|
||||||
|
|
||||||
|
### Shutdown Process
|
||||||
|
|
||||||
|
The transport implements a graceful shutdown mechanism that:
|
||||||
|
1. Marks transport as closing
|
||||||
|
2. Disposes of all subscriptions
|
||||||
|
3. Cleans up schedulers
|
||||||
|
4. Completes message endpoint sink
|
||||||
|
|
||||||
|
## Technical Specifications
|
||||||
|
|
||||||
|
- **Protocol**: HTTP with Server-Sent Events (SSE)
|
||||||
|
- **Message Format**: JSON-RPC 2.0
|
||||||
|
- **Content Type**: application/json for outbound, text/event-stream for inbound
|
||||||
|
- **Endpoints**:
|
||||||
|
- SSE Connection: `/sse`
|
||||||
|
- Message Endpoint: Dynamically provided by server
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spring WebFlux for reactive HTTP client
|
||||||
|
- Jackson for JSON processing
|
||||||
|
- Project Reactor for reactive streams
|
||||||
|
- SLF4J for logging
|
||||||
|
|
||||||
|
## Thread Safety
|
||||||
|
|
||||||
|
The implementation is thread-safe and designed for concurrent use:
|
||||||
|
- Inbound processing uses SSE event loop
|
||||||
|
- Outbound processing uses dedicated single-threaded scheduler
|
||||||
|
- State management uses thread-safe constructs
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The implementation includes comprehensive tests covering:
|
||||||
|
- Message processing
|
||||||
|
- Error handling
|
||||||
|
- Connection management
|
||||||
|
- Shutdown procedures
|
||||||
|
- Edge cases and failure scenarios
|
||||||
|
|
||||||
|
For detailed test examples, see `SseClientTransportTests.java`.
|
||||||
123
docs/CLIENT.STDIO.TRANSPORT.md
Normal file
123
docs/CLIENT.STDIO.TRANSPORT.md
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# StdioClientTransport
|
||||||
|
|
||||||
|
The `StdioClientTransport` class implements the [MCP Stdio Transport](https://spec.modelcontextprotocol.io/specification/basic/transports/#stdio) specification, providing communication with MCP servers over standard input/output streams.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Stdio transport uses standard input/output streams for bidirectional communication between the client and server:
|
||||||
|
- Messages are sent to the server via stdout
|
||||||
|
- Messages are received from the server via stdin
|
||||||
|
- Error and debug information is sent via stderr
|
||||||
|
|
||||||
|
## Message Format
|
||||||
|
|
||||||
|
Messages are exchanged as newline-delimited JSON-RPC messages:
|
||||||
|
- Each message is a single line of JSON text
|
||||||
|
- Messages are terminated with a newline character (`\n`)
|
||||||
|
- Messages follow the JSON-RPC 2.0 specification
|
||||||
|
- UTF-8 encoding is used for all messages
|
||||||
|
|
||||||
|
### Message Types
|
||||||
|
|
||||||
|
The transport handles three types of JSON-RPC messages:
|
||||||
|
|
||||||
|
1. **Requests** - Messages with both `method` and `id` fields:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "example.method",
|
||||||
|
"params": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Notifications** - Messages with `method` but no `id` field:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "example.notification",
|
||||||
|
"params": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Responses** - Messages with either `result` or `error` fields:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Process Management
|
||||||
|
|
||||||
|
The transport manages a server process with:
|
||||||
|
- Command and arguments specified via `ServerParameters`
|
||||||
|
- Environment variables for configuration
|
||||||
|
- Proper process cleanup on shutdown
|
||||||
|
|
||||||
|
### Threading Model
|
||||||
|
|
||||||
|
The implementation uses three dedicated threads:
|
||||||
|
1. **Inbound** - Reads and processes messages from stdin
|
||||||
|
2. **Outbound** - Writes messages to stdout
|
||||||
|
3. **Error** - Handles stderr messages
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Errors are handled at multiple levels:
|
||||||
|
- Process startup failures
|
||||||
|
- Stream I/O errors
|
||||||
|
- Message parsing errors
|
||||||
|
- Process termination handling
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Create server parameters
|
||||||
|
ServerParameters params = ServerParameters.builder()
|
||||||
|
.command("path/to/server")
|
||||||
|
.args(List.of("--arg1", "value1"))
|
||||||
|
.env(Map.of("ENV_VAR", "value"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Create transport
|
||||||
|
StdioClientTransport transport = new StdioClientTransport(params);
|
||||||
|
|
||||||
|
// Start transport
|
||||||
|
transport.start();
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
JSONRPCRequest request = new JSONRPCRequest();
|
||||||
|
request.setId(1);
|
||||||
|
request.setMethod("example.method");
|
||||||
|
request.setParams(new HashMap<>());
|
||||||
|
transport.send(request);
|
||||||
|
|
||||||
|
// Close transport
|
||||||
|
transport.closeGracefully().block();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Messages
|
||||||
|
|
||||||
|
Error messages from the server process are:
|
||||||
|
- Written to stderr
|
||||||
|
- Logged at ERROR level
|
||||||
|
- Emitted through the error sink for handling
|
||||||
|
- UTF-8 encoded
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
For debugging purposes:
|
||||||
|
1. Enable DEBUG level logging to see detailed message flow
|
||||||
|
2. Monitor stderr for server process errors
|
||||||
|
3. Check process exit codes for abnormal termination
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- The transport ensures thread safety through dedicated schedulers
|
||||||
|
- Messages are processed asynchronously using Project Reactor
|
||||||
|
- Resources are properly cleaned up on shutdown
|
||||||
|
- The implementation is resilient to various failure modes
|
||||||
78
docs/MCP-SESSION.md
Normal file
78
docs/MCP-SESSION.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# MCP Session Components
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The MCP (Model Context Protocol) session components implement the [MCP specification](https://spec.modelcontextprotocol.io/) for managing bidirectional JSON-RPC communication between clients and model servers.
|
||||||
|
The implementation provides a robust foundation for request-response patterns and notification handling.
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
### McpSession Interface
|
||||||
|
|
||||||
|
The `McpSession` interface defines the contract for MCP communication with three primary operations:
|
||||||
|
|
||||||
|
1. Request-Response Communication
|
||||||
|
```java
|
||||||
|
<T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Notification Handling
|
||||||
|
```java
|
||||||
|
Mono<Void> sendNotification(String method, Map<String, Object> params);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Session Lifecycle Management
|
||||||
|
```java
|
||||||
|
Mono<Void> closeGracefully();
|
||||||
|
```
|
||||||
|
|
||||||
|
### DefaultMcpSession Implementation
|
||||||
|
|
||||||
|
The `DefaultMcpSession` provides the concrete implementation of the MCP session with the following key features:
|
||||||
|
|
||||||
|
#### 1. Message Correlation
|
||||||
|
- Uses unique request IDs combining session UUID prefix and atomic counter
|
||||||
|
- Maintains thread-safe tracking of pending responses
|
||||||
|
- Implements timeout management for requests
|
||||||
|
|
||||||
|
#### 2. Handler Registration
|
||||||
|
```java
|
||||||
|
public interface RequestHandler {
|
||||||
|
Mono<Object> handle(Object params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface NotificationHandler {
|
||||||
|
Mono<Void> handle(Object params);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Transport Integration
|
||||||
|
- Abstracts transport details through `McpTransport` interface
|
||||||
|
- Supports different transport implementations (SSE, STDIO)
|
||||||
|
- Manages message serialization using Jackson
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
- The session processes three types of messages: McpSchema.JSONRPCResponse, McpSchema.JSONRPCRequest, McpSchema.JSONRPCNotificaiton.
|
||||||
|
- The implementation ensures thread safety through
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
WIP
|
||||||
|
|
||||||
|
## Integration Guidelines
|
||||||
|
|
||||||
|
1. **Transport Selection**
|
||||||
|
- Choose appropriate transport (SSE, STDIO) based on use case
|
||||||
|
- Configure transport-specific parameters
|
||||||
|
- Handle transport lifecycle properly
|
||||||
|
|
||||||
|
2. **Message Handling**
|
||||||
|
- Register handlers before starting session
|
||||||
|
- Use appropriate error codes from MCP specification
|
||||||
|
- Implement proper request/response correlation
|
||||||
|
|
||||||
|
3. **Type Safety**
|
||||||
|
- Use appropriate TypeReference for response deserialization
|
||||||
|
- Validate request parameters
|
||||||
|
- Handle type conversion errors
|
||||||
132
docs/class-diagrams.puml
Normal file
132
docs/class-diagrams.puml
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
@startuml Core Components
|
||||||
|
|
||||||
|
interface McpTransport {
|
||||||
|
+Mono<Void> connect(Function<Mono<JSONRPCMessage>,
|
||||||
|
Mono<JSONRPCMessage>> handler)
|
||||||
|
+Mono<Void> sendMessage()
|
||||||
|
+void close()
|
||||||
|
+Mono<Void> closeGracefully()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpSession {
|
||||||
|
+<T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef)
|
||||||
|
+Mono<Void> sendNotification(String method, Map<String, Object> params)
|
||||||
|
+Mono<Void> closeGracefully()
|
||||||
|
+close()
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultMcpSession {
|
||||||
|
-McpTransport transport
|
||||||
|
-ObjectMapper objectMapper
|
||||||
|
}
|
||||||
|
|
||||||
|
class McpClient {
|
||||||
|
+{static} async()
|
||||||
|
+{static} sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
class McpAsyncClient {
|
||||||
|
+Mono<InitializeResult> initialize()
|
||||||
|
+Mono<CallToolResult> callTool()
|
||||||
|
+Mono<ListToolsResult> listTools()
|
||||||
|
+Mono<ListResourcesResult> listResources()
|
||||||
|
+Mono<ReadResourceResult> readResource()
|
||||||
|
+Mono<ListPromptsResult> listPrompts()
|
||||||
|
+Mono<GetPromptResult> getPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
|
class McpSyncClient {
|
||||||
|
+InitializeResult initialize()
|
||||||
|
+CallToolResult callTool()
|
||||||
|
+ListToolsResult listTools()
|
||||||
|
+ListResourcesResult listResources()
|
||||||
|
+ReadResourceResult readResource()
|
||||||
|
+ListPromptsResult listPrompts()
|
||||||
|
+GetPromptResult getPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
|
class StdioClientTransport {
|
||||||
|
-Process serverProcess
|
||||||
|
-ServerParameters parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
class SseClientTransport {
|
||||||
|
-WebClient webClient
|
||||||
|
}
|
||||||
|
|
||||||
|
class McpSchema {
|
||||||
|
+class ErrorCodes
|
||||||
|
+interface Request
|
||||||
|
+interface JSONRPCMessage
|
||||||
|
+interface ResourceContents
|
||||||
|
+interface Content
|
||||||
|
}
|
||||||
|
|
||||||
|
class McpError {
|
||||||
|
-int code
|
||||||
|
-String message
|
||||||
|
}
|
||||||
|
|
||||||
|
McpTransport <|-- StdioClientTransport
|
||||||
|
McpTransport <|-- SseClientTransport
|
||||||
|
McpSession <|-- DefaultMcpSession
|
||||||
|
DefaultMcpSession --o McpAsyncClient
|
||||||
|
McpClient ..> McpAsyncClient : creates
|
||||||
|
McpClient ..> McpSyncClient : creates
|
||||||
|
McpSyncClient o-- McpAsyncClient
|
||||||
|
DefaultMcpSession o-- McpTransport
|
||||||
|
McpSchema <.. McpSession : uses
|
||||||
|
McpError ..> McpSession : throws
|
||||||
|
|
||||||
|
@enduml
|
||||||
|
|
||||||
|
@startuml Message Flow
|
||||||
|
|
||||||
|
package "MCP Schema" {
|
||||||
|
interface JSONRPCMessage {
|
||||||
|
+String jsonrpc()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Request {
|
||||||
|
}
|
||||||
|
|
||||||
|
class InitializeRequest
|
||||||
|
class CallToolRequest
|
||||||
|
class CreateMessageRequest
|
||||||
|
class CompleteRequest
|
||||||
|
class GetPromptRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Resource Types" {
|
||||||
|
interface ResourceContents {
|
||||||
|
+String uri()
|
||||||
|
+String mimeType()
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextResourceContents
|
||||||
|
class BlobResourceContents
|
||||||
|
|
||||||
|
interface Content {
|
||||||
|
+String type()
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextContent
|
||||||
|
class ImageContent
|
||||||
|
class EmbeddedResource
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONRPCMessage <|.. Request
|
||||||
|
Request <|.. InitializeRequest
|
||||||
|
Request <|.. CallToolRequest
|
||||||
|
Request <|.. CreateMessageRequest
|
||||||
|
Request <|.. CompleteRequest
|
||||||
|
Request <|.. GetPromptRequest
|
||||||
|
|
||||||
|
ResourceContents <|.. TextResourceContents
|
||||||
|
ResourceContents <|.. BlobResourceContents
|
||||||
|
|
||||||
|
Content <|.. TextContent
|
||||||
|
Content <|.. ImageContent
|
||||||
|
Content <|.. EmbeddedResource
|
||||||
|
|
||||||
|
@enduml
|
||||||
BIN
docs/spring-ai-mcp-architecture.jpg
Normal file
BIN
docs/spring-ai-mcp-architecture.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 736 KiB |
1
docs/spring-ai-mcp-uml-classdiagram.svg
Normal file
1
docs/spring-ai-mcp-uml-classdiagram.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -15,6 +15,7 @@ This SDK implements the Model Context Protocol, enabling seamless integration wi
|
||||||
- Tool discovery and execution
|
- Tool discovery and execution
|
||||||
- Resource management with URI templates
|
- Resource management with URI templates
|
||||||
- Resource subscription system
|
- Resource subscription system
|
||||||
|
- Roots list management and notifications
|
||||||
- Prompt handling and management
|
- Prompt handling and management
|
||||||
- Server initialization and ping
|
- Server initialization and ping
|
||||||
- Multiple transport implementations:
|
- Multiple transport implementations:
|
||||||
|
|
@ -72,6 +73,43 @@ The SSE transport provides:
|
||||||
- Graceful shutdown handling
|
- Graceful shutdown handling
|
||||||
- Configurable JSON serialization
|
- 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
|
### Sync Client Example
|
||||||
|
|
||||||
```java
|
```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
|
- **StdioClientTransport**: Standard I/O based client to server communication
|
||||||
- **SseClientTransport**: HTTP-based transport using Server-Sent Events for bidirectional client-sever 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
|
### Key Interactions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,27 @@
|
||||||
package org.springframework.ai.mcp.client;
|
package org.springframework.ai.mcp.client;
|
||||||
|
|
||||||
import java.time.Duration;
|
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 com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import org.springframework.ai.mcp.spec.DefaultMcpSession;
|
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.McpError;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema;
|
import org.springframework.ai.mcp.spec.McpSchema;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
|
import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
|
import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
|
import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.PaginatedRequest;
|
import org.springframework.ai.mcp.spec.McpSchema.PaginatedRequest;
|
||||||
|
import org.springframework.ai.mcp.spec.McpSchema.Root;
|
||||||
import org.springframework.ai.mcp.spec.McpTransport;
|
import org.springframework.ai.mcp.spec.McpTransport;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,23 +60,62 @@ public class McpAsyncClient {
|
||||||
private final DefaultMcpSession mcpSession;
|
private final DefaultMcpSession mcpSession;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new McpAsyncClient with the given transport.
|
* Roots define the boundaries of where servers can operate within the filesystem,
|
||||||
* @param transport the transport to use.
|
* 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) {
|
private McpSchema.ClientCapabilities.RootCapabilities rootCapabilities;
|
||||||
this(transport, Duration.ofSeconds(20));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new McpAsyncClient with the given transport and session request-response
|
* Create a new McpAsyncClient with the given transport and session request-response
|
||||||
* timeout.
|
* timeout.
|
||||||
* @param transport the transport to use.
|
* @param transport the transport to use.
|
||||||
* @param requestTimeout the session request-response timeout.
|
* @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) {
|
public McpAsyncClient(McpTransport transport, Duration requestTimeout,
|
||||||
this.mcpSession = new DefaultMcpSession(requestTimeout, transport);
|
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.
|
* The initialization phase MUST be the first interaction between client and server.
|
||||||
* During this phase, the client and server:
|
* During this phase, the client and server:
|
||||||
|
|
@ -100,8 +147,8 @@ public class McpAsyncClient {
|
||||||
public Mono<McpSchema.InitializeResult> initialize() {
|
public Mono<McpSchema.InitializeResult> initialize() {
|
||||||
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
|
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
|
||||||
McpSchema.LATEST_PROTOCOL_VERSION,
|
McpSchema.LATEST_PROTOCOL_VERSION,
|
||||||
new McpSchema.ClientCapabilities(null, new McpSchema.ClientCapabilities.RootCapabilities(true), null),
|
new McpSchema.ClientCapabilities(null, rootCapabilities, null),
|
||||||
new McpSchema.Implementation("mcp-java-client", "0.0.1")); // @formatter:on
|
new McpSchema.Implementation("mcp-java-client", "0.2.0")); // @formatter:on
|
||||||
|
|
||||||
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest("initialize", initializeRequest,
|
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest("initialize", initializeRequest,
|
||||||
new TypeReference<McpSchema.InitializeResult>() {
|
new TypeReference<McpSchema.InitializeResult>() {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,11 @@
|
||||||
package org.springframework.ai.mcp.client;
|
package org.springframework.ai.mcp.client;
|
||||||
|
|
||||||
import java.time.Duration;
|
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.spec.McpTransport;
|
||||||
import org.springframework.ai.mcp.util.Assert;
|
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.
|
* including request timeout and JSON object mapping.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Use the builder pattern for flexible client configuration: <pre>{@code
|
* Use the builder pattern for flexible client configuration:
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
* McpClient.using(transport)
|
* McpClient.using(transport)
|
||||||
* .withRequestTimeout(Duration.ofSeconds(5))
|
* .withRequestTimeout(Duration.ofSeconds(5))
|
||||||
* .sync(); // or .async()
|
* .sync(); // or .async()
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
*
|
||||||
* @author Christian Tzolov
|
* @author Christian Tzolov
|
||||||
|
|
@ -74,6 +80,10 @@ public class McpClient {
|
||||||
|
|
||||||
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
|
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
|
||||||
|
|
||||||
|
private boolean rootsListChangedNotification = false;
|
||||||
|
|
||||||
|
private List<Supplier<List<Root>>> rootsListProviders = new ArrayList<>();
|
||||||
|
|
||||||
private Builder(McpTransport transport) {
|
private Builder(McpTransport transport) {
|
||||||
Assert.notNull(transport, "Transport must not be null");
|
Assert.notNull(transport, "Transport must not be null");
|
||||||
this.transport = transport;
|
this.transport = transport;
|
||||||
|
|
@ -84,12 +94,22 @@ public class McpClient {
|
||||||
* @param requestTimeout The duration to wait before timing out requests
|
* @param requestTimeout The duration to wait before timing out requests
|
||||||
* @return This builder instance
|
* @return This builder instance
|
||||||
*/
|
*/
|
||||||
public Builder withRequestTimeout(Duration requestTimeout) {
|
public Builder requestTimeout(Duration requestTimeout) {
|
||||||
Assert.notNull(requestTimeout, "Request timeout must not be null");
|
Assert.notNull(requestTimeout, "Request timeout must not be null");
|
||||||
this.requestTimeout = requestTimeout;
|
this.requestTimeout = requestTimeout;
|
||||||
return this;
|
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.
|
* Build a synchronous MCP client.
|
||||||
* @return A new instance of {@link McpSyncClient}
|
* @return A new instance of {@link McpSyncClient}
|
||||||
|
|
@ -103,7 +123,7 @@ public class McpClient {
|
||||||
* @return A new instance of {@link McpAsyncClient}
|
* @return A new instance of {@link McpAsyncClient}
|
||||||
*/
|
*/
|
||||||
public McpAsyncClient async() {
|
public McpAsyncClient async() {
|
||||||
return new McpAsyncClient(transport, requestTimeout);
|
return new McpAsyncClient(transport, requestTimeout, rootsListProviders, rootsListChangedNotification);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -121,7 +141,7 @@ public class McpClient {
|
||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
public static McpAsyncClient async(McpTransport transport, Duration requestTimeout) {
|
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
|
@Deprecated
|
||||||
public static McpSyncClient sync(McpTransport transport, Duration requestTimeout) {
|
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.
|
* Creates a new DefaultMcpSession with the specified configuration and handlers.
|
||||||
* @param requestTimeout Duration to wait for responses
|
* @param requestTimeout Duration to wait for responses
|
||||||
|
|
|
||||||
|
|
@ -155,16 +155,53 @@ public class McpSchema {
|
||||||
@JsonProperty("instructions") String instructions) {
|
@JsonProperty("instructions") String instructions) {
|
||||||
} // @formatter:on
|
} // @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)
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
public record ClientCapabilities( // @formatter:off
|
public record ClientCapabilities( // @formatter:off
|
||||||
@JsonProperty("experimental") Map<String, Object> experimental,
|
@JsonProperty("experimental") Map<String, Object> experimental,
|
||||||
@JsonProperty("roots") RootCapabilities roots,
|
@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)
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
public record RootCapabilities(
|
public record RootCapabilities(
|
||||||
@JsonProperty("listChanged") Boolean listChanged) {
|
@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
|
}// @formatter:on
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ public class ClientSessionTests2 {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
try (McpSyncClient clientSession = McpClient.using(new StdioClientTransport(stdioParams))
|
try (McpSyncClient clientSession = McpClient.using(new StdioClientTransport(stdioParams))
|
||||||
.withRequestTimeout(Duration.ofSeconds(10))
|
.requestTimeout(Duration.ofSeconds(10))
|
||||||
.sync()) {
|
.sync()) {
|
||||||
|
|
||||||
clientSession.initialize();
|
clientSession.initialize();
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ public class ClientSessionTests3 {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
clientSession = McpClient.using(new StdioClientTransport(stdioParams))
|
clientSession = McpClient.using(new StdioClientTransport(stdioParams))
|
||||||
.withRequestTimeout(Duration.ofSeconds(10))
|
.requestTimeout(Duration.ofSeconds(10))
|
||||||
.sync();
|
.sync();
|
||||||
|
|
||||||
clientSession.initialize();
|
clientSession.initialize();
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ public class MainSSE {
|
||||||
|
|
||||||
try (McpSyncClient mcpClient = McpClient
|
try (McpSyncClient mcpClient = McpClient
|
||||||
.using(new SseClientTransport(WebClient.builder().baseUrl("http://localhost:3001")))
|
.using(new SseClientTransport(WebClient.builder().baseUrl("http://localhost:3001")))
|
||||||
.withRequestTimeout(Duration.ofSeconds(1000))
|
.requestTimeout(Duration.ofSeconds(1000))
|
||||||
.sync()) {
|
.sync()) {
|
||||||
|
|
||||||
mcpClient.initialize();
|
mcpClient.initialize();
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@
|
||||||
package org.springframework.ai.mcp.client;
|
package org.springframework.ai.mcp.client;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
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.GetPromptRequest;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.Prompt;
|
import org.springframework.ai.mcp.spec.McpSchema.Prompt;
|
||||||
import org.springframework.ai.mcp.spec.McpSchema.Resource;
|
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.McpSchema.Tool;
|
||||||
import org.springframework.ai.mcp.spec.McpTransport;
|
import org.springframework.ai.mcp.spec.McpTransport;
|
||||||
|
|
||||||
|
|
@ -64,7 +67,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
||||||
this.mcpTransport = createMcpTransport();
|
this.mcpTransport = createMcpTransport();
|
||||||
|
|
||||||
assertThatCode(() -> {
|
assertThatCode(() -> {
|
||||||
mcpAsyncClient = McpClient.using(mcpTransport).withRequestTimeout(TIMEOUT).async();
|
mcpAsyncClient = McpClient.using(mcpTransport).requestTimeout(TIMEOUT).async();
|
||||||
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
|
||||||
}).doesNotThrowAnyException();
|
}).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +86,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
||||||
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
|
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessage("Transport must not be null");
|
.hasMessage("Transport must not be null");
|
||||||
|
|
||||||
assertThatThrownBy(() -> McpClient.using(mcpTransport).withRequestTimeout(null).sync())
|
assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessage("Request timeout must not be null");
|
.hasMessage("Request timeout must not be null");
|
||||||
}
|
}
|
||||||
|
|
@ -123,11 +126,6 @@ public abstract class AbstractMcpAsyncClientTests {
|
||||||
assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class);
|
assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void testRootsListChanged() {
|
|
||||||
assertThatCode(() -> mcpAsyncClient.sendRootsListChanged().block()).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testListResources() {
|
void testListResources() {
|
||||||
StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> {
|
StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> {
|
||||||
|
|
@ -175,4 +173,21 @@ public abstract class AbstractMcpAsyncClientTests {
|
||||||
.verifyComplete();
|
.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();
|
this.mcpTransport = createMcpTransport();
|
||||||
|
|
||||||
assertThatCode(() -> {
|
assertThatCode(() -> {
|
||||||
mcpSyncClient = McpClient.using(mcpTransport).withRequestTimeout(TIMEOUT).sync();
|
mcpSyncClient = McpClient.using(mcpTransport).requestTimeout(TIMEOUT).sync();
|
||||||
mcpSyncClient.initialize();
|
mcpSyncClient.initialize();
|
||||||
}).doesNotThrowAnyException();
|
}).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +82,7 @@ public abstract class AbstractMcpSyncClientTests {
|
||||||
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
|
assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessage("Transport must not be null");
|
.hasMessage("Transport must not be null");
|
||||||
|
|
||||||
assertThatThrownBy(() -> McpClient.using(mcpTransport).withRequestTimeout(null).sync())
|
assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync())
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessage("Request timeout must not be null");
|
.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 MockMcpTransport transport;
|
||||||
|
|
||||||
private ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
private static class MockMcpTransport implements McpTransport {
|
private static class MockMcpTransport implements McpTransport {
|
||||||
|
|
||||||
|
|
@ -124,7 +122,6 @@ class DefaultMcpSessionTests {
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
objectMapper = new ObjectMapper();
|
|
||||||
transport = new MockMcpTransport();
|
transport = new MockMcpTransport();
|
||||||
session = new DefaultMcpSession(TIMEOUT, transport, Map.of(),
|
session = new DefaultMcpSession(TIMEOUT, transport, Map.of(),
|
||||||
Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: " + params))));
|
Map.of(TEST_NOTIFICATION, params -> Mono.fromRunnable(() -> logger.info("Status update: " + params))));
|
||||||
|
|
@ -139,10 +136,12 @@ class DefaultMcpSessionTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testConstructorWithInvalidArguments() {
|
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");
|
.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");
|
.hasMessageContaining("transport can not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue