diff --git a/0.5.0-BREAKING-CHANGES.md b/0.5.0-BREAKING-CHANGES.md new file mode 100644 index 0000000..7321b16 --- /dev/null +++ b/0.5.0-BREAKING-CHANGES.md @@ -0,0 +1,105 @@ +# Breaking Changes for Spring AI MCP 0.5.0-SNAPSHOT + +## Major Changes + +1. Transport Implementation Modularization + - SSE transport implementations have been moved to dedicated modules: + - `mcp-webflux-sse-transport`: WebFlux-based SSE transport + - `mcp-webmvc-sse-transport`: WebMVC-based SSE transport + - Base MCP module no longer includes transport implementations + +2. Class Renames + - `SseClientTransport` → `WebFluxSseClientTransport` + - `SseServerTransport` → `WebFluxSseServerTransport` + +3. Project Structure + - Test utilities moved to new `mcp-test` module + - Sample application moved to `samples/` directory + +## Migration Guide + +### 1. Update Dependencies + +If using SSE transport, add the appropriate transport module to your pom.xml: + +For WebFlux SSE transport: +```xml + + org.springframework.experimental + mcp-webflux-sse-transport + 0.5.0-SNAPSHOT + +``` + +For WebMVC SSE transport: +```xml + + org.springframework.experimental + mcp-webmvc-sse-transport + 0.5.0-SNAPSHOT + +``` + +### 2. Update Transport Class References + +Replace: +```java +import org.springframework.ai.mcp.client.transport.SseClientTransport; +import org.springframework.ai.mcp.server.transport.SseServerTransport; +``` + +With: +```java +// For WebFlux +import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport; +import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport; + +// Or for WebMVC +import org.springframework.ai.mcp.server.transport.WebMvcSseServerTransport; +``` + +### 3. Update Transport Instantiation + +Replace: +```java +var transport = new SseClientTransport(webClientBuilder); +``` + +With: +```java +var transport = new WebFluxSseClientTransport(webClientBuilder); +``` + +Replace: +```java +var serverTransport = new SseServerTransport(objectMapper, messageEndpoint); +``` + +With: +```java +// For WebFlux +var serverTransport = new WebFluxSseServerTransport(objectMapper, messageEndpoint); + +// Or for WebMVC +var serverTransport = new WebMvcSseServerTransport(objectMapper, messageEndpoint); +``` + +### 4. Test Dependencies + +If you're using MCP test utilities, add the test module dependency: + +```xml + + org.springframework.experimental + mcp-test + 0.5.0-SNAPSHOT + test + +``` + +## Other Changes + +- Spring AI dependency updated to 1.0.0-M5 +- Improved dependency management and reduced transitive dependencies +- Sample application restructured under samples/ directory +- New test utilities and base test classes in mcp-test module diff --git a/README.md b/README.md index 486e2be..404d899 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Explore these MCP examples in the [spring-ai-examples/model-context-protocol](ht - [SQLite Chatbot](https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/sqlite/chatbot) - Interactive chatbot with SQLite database interaction - [Filesystem](https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/filesystem) - Enables LLM interaction with local filesystem folders and files - [Brave](https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/brave) - Enables natural language interactions with Brave Search, allowing you to perform internet searches. -- [Spring-ai-mcp-sample](./spring-ai-mcp-sample/) - Showcases how to create and use MCP servers and clients with different transport modes and capabilities. +- [Spring-ai-mcp-sample](./samples/spring-ai-mcp-sample/) - Showcases how to create and use MCP servers and clients with different transport modes and capabilities. ## Documentation - [Java MCP SDK documentation](mcp/README.md) diff --git a/docs/CLIENT.SSE.TRANSPORT.md b/docs/CLIENT.SSE.TRANSPORT.md deleted file mode 100644 index e65794f..0000000 --- a/docs/CLIENT.SSE.TRANSPORT.md +++ /dev/null @@ -1,144 +0,0 @@ -# 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`. diff --git a/docs/CLIENT.STDIO.TRANSPORT.md b/docs/CLIENT.STDIO.TRANSPORT.md deleted file mode 100644 index 1c2dc6f..0000000 --- a/docs/CLIENT.STDIO.TRANSPORT.md +++ /dev/null @@ -1,123 +0,0 @@ -# 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 diff --git a/docs/class-diagrams.puml b/docs/class-diagrams.puml deleted file mode 100644 index e68059c..0000000 --- a/docs/class-diagrams.puml +++ /dev/null @@ -1,204 +0,0 @@ -@startuml Core Components - -' Core Interfaces -interface McpTransport { - +Mono connect(Function, Mono> handler) - +Mono sendMessage(JSONRPCMessage message) - +void close() - +Mono closeGracefully() - + T unmarshalFrom(Object data, TypeReference typeRef) -} - -interface McpSession { - + Mono sendRequest(String method, Object requestParams, TypeReference typeRef) - +Mono sendNotification(String method, Map params) - +Mono closeGracefully() - +void close() -} - -' Core Implementation Classes -class DefaultMcpSession { - -McpTransport transport - -ObjectMapper objectMapper - +interface RequestHandler - +interface NotificationHandler -} - -' Client Classes -class McpClient { - +{static} Builder using(McpTransport transport) - +{static} McpAsyncClient async(McpTransport transport) - +{static} McpSyncClient sync(McpTransport transport) -} - -class McpAsyncClient { - +Mono initialize() - +Mono callTool(CallToolRequest request) - +Mono listTools() - +Mono listResources() - +Mono readResource(ReadResourceRequest request) - +Mono listResourceTemplates() - +Mono listPrompts() - +Mono getPrompt(GetPromptRequest request) -} - -class McpSyncClient { - +InitializeResult initialize() - +CallToolResult callTool(CallToolRequest request) - +ListToolsResult listTools() - +ListResourcesResult listResources() - +ReadResourceResult readResource(ReadResourceRequest request) - +ListResourceTemplatesResult listResourceTemplates() - +ListPromptsResult listPrompts() - +GetPromptResult getPrompt(GetPromptRequest request) -} - -' Server Classes -class McpServer { - +{static} Builder using(McpTransport transport) -} - -class McpAsyncServer { - +Mono addTool(ToolHandler toolHandler) - +Mono removeTool(String toolName) - +Mono notifyToolsListChanged() - +Mono notifyResourcesListChanged() - +Mono notifyPromptsListChanged() - +interface ToolHandler -} - -class McpSyncServer { - +void addTool(ToolHandler toolHandler) - +void removeTool(String toolName) - +void notifyToolsListChanged() - +void notifyResourcesListChanged() - +void notifyPromptsListChanged() - +McpAsyncServer getAsyncServer() -} - -' Transport Implementations -class StdioClientTransport { - -Process serverProcess - -ServerParameters parameters -} - -class SseClientTransport { - -WebClient webClient -} - -class StdioServerTransport { -} - -class SseServerTransport { -} - -' Schema and Error Classes -class McpSchema { - +class ErrorCodes - +interface Request - +interface JSONRPCMessage - +interface ResourceContents - +interface Content - +interface ServerCapabilities - +{static} JSONRPCMessage deserializeJsonRpcMessage() -} - -class McpError { - -int code - -String message -} - -' Relationships -McpTransport <|.. StdioClientTransport -McpTransport <|.. SseClientTransport -McpTransport <|.. StdioServerTransport -McpTransport <|.. SseServerTransport - -McpSession <|.. DefaultMcpSession -DefaultMcpSession --o McpAsyncClient -DefaultMcpSession --o McpAsyncServer - -McpClient ..> McpAsyncClient : creates -McpClient ..> McpSyncClient : creates -McpSyncClient o-- McpAsyncClient - -McpServer ..> McpAsyncServer : creates -McpServer ..> McpSyncServer : creates -McpSyncServer o-- McpAsyncServer - -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 ListToolsRequest - class ListResourcesRequest - class ReadResourceRequest - class ListResourceTemplatesRequest - class ListPromptsRequest - 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 - - interface Annotated { - +Annotations annotations() - } - - interface PromptOrResourceReference { - +String type() - } - - class PromptReference - class ResourceReference -} - -JSONRPCMessage <|.. Request -Request <|.. InitializeRequest -Request <|.. CallToolRequest -Request <|.. ListToolsRequest -Request <|.. ListResourcesRequest -Request <|.. ReadResourceRequest -Request <|.. ListResourceTemplatesRequest -Request <|.. ListPromptsRequest -Request <|.. GetPromptRequest - -ResourceContents <|.. TextResourceContents -ResourceContents <|.. BlobResourceContents - -Content <|.. TextContent -Content <|.. ImageContent -Content <|.. EmbeddedResource - -PromptOrResourceReference <|.. PromptReference -PromptOrResourceReference <|.. ResourceReference - -@enduml diff --git a/docs/ref-client.md b/docs/ref-client.md deleted file mode 100644 index f0767bd..0000000 --- a/docs/ref-client.md +++ /dev/null @@ -1,299 +0,0 @@ -# MCP Client Reference - -## McpClient Factory - -`org.springframework.ai.mcp.client.McpClient` - -The `McpClient` class serves as a factory for creating Model Context Protocol (MCP) clients. It provides the main entry point for establishing connections with MCP servers, offering both synchronous and asynchronous client implementations. - -### Overview - -The client factory uses a builder pattern for flexible configuration, allowing you to customize various aspects of the client including timeout durations, notification handlers, and more. - -### Client Types - -The factory can create two types of clients: -- `McpAsyncClient` for non-blocking operations -- `McpSyncClient` for blocking operations - -### Builder Configuration - -#### Basic Usage -```java -McpClient.using(transport) - .requestTimeout(Duration.ofSeconds(5)) - .sync(); // or .async() -``` - -#### Builder Methods - -##### using(McpTransport transport) -```java -public static Builder using(McpTransport transport) -``` -Start building an MCP client with the specified transport. - -##### requestTimeout(Duration requestTimeout) -```java -public Builder requestTimeout(Duration requestTimeout) -``` -Set the request timeout duration (default is 20 seconds). - -##### capabilities(ClientCapabilities capabilities) -```java -public Builder capabilities(ClientCapabilities capabilities) -``` -Set the client capabilities that define what features the client supports. - -##### clientInfo(Implementation clientInfo) -```java -public Builder clientInfo(Implementation clientInfo) -``` -Set the client implementation information (name and version). - -##### roots(List roots) -```java -public Builder roots(List roots) -``` -Add a list of roots that define filesystem boundaries for server operations. - -##### roots(Root... roots) -```java -public Builder roots(Root... roots) -``` -Add roots that define filesystem boundaries for server operations. - -##### toolsChangeConsumer(Consumer> consumer) -```java -public Builder toolsChangeConsumer(Consumer> toolsChangeConsumer) -``` -Add a consumer for tool changes notifications. - -##### resourcesChangeConsumer(Consumer> consumer) -```java -public Builder resourcesChangeConsumer(Consumer> resourcesChangeConsumer) -``` -Add a consumer for resource changes notifications. - -##### promptsChangeConsumer(Consumer> consumer) -```java -public Builder promptsChangeConsumer(Consumer> promptsChangeConsumer) -``` -Add a consumer for prompt changes notifications. - -##### sync() -```java -public McpSyncClient sync() -``` -Build and return a synchronous MCP client. - -##### async() -```java -public McpAsyncClient async() -``` -Build and return an asynchronous MCP client. - -### Usage Examples - -#### Creating a Synchronous Client -```java -McpTransport transport = // obtain transport instance - -// Configure client capabilities -ClientCapabilities capabilities = ClientCapabilities.builder() - .experimental(Map.of("feature", "value")) - .roots(true) - .sampling() - .build(); - -McpSyncClient client = McpClient.using(transport) - .requestTimeout(Duration.ofSeconds(10)) - .rootsListChangedNotification(true) - .toolsChangeConsumer(tools -> { - System.out.println("Tools updated: " + tools); - }) - .sync(); - -// Initialize client with capabilities -client.initialize(LATEST_PROTOCOL_VERSION, capabilities, - new Implementation("client-name", "1.0.0")); -``` - -#### Creating an Asynchronous Client -```java -McpTransport transport = // obtain transport instance -McpAsyncClient client = McpClient.using(transport) - .requestTimeout(Duration.ofSeconds(5)) - .resourcesChangeConsumer(resources -> { - System.out.println("Resources updated: " + resources); - }) - .async(); -``` - -#### Using Multiple Change Consumers -```java -McpClient.using(transport) - .toolsChangeConsumer(tools -> { - // Handle tool changes - }) - .toolsChangeConsumer(tools -> { - // Additional tool change handling - }) - .resourcesChangeConsumer(resources -> { - // Handle resource changes - }) - .sync(); -``` - -### Client Capabilities - -The `ClientCapabilities` class represents features that clients can implement to enrich connected MCP servers. It uses a builder pattern for easy configuration. - -#### ClientCapabilities Builder - -```java -ClientCapabilities capabilities = ClientCapabilities.builder() - .experimental(experimentalMap) - .roots(true) - .sampling() - .build(); -``` - -##### Builder Methods - -###### experimental(Map experimental) -```java -public Builder experimental(Map experimental) -``` -Set experimental features map for work-in-progress capabilities. - -###### roots(Boolean listChanged) -```java -public Builder roots(Boolean listChanged) -``` -Configure root capabilities with listChanged parameter. Roots define the boundaries of where servers can operate within the filesystem. - -###### sampling() -```java -public Builder sampling() -``` -Add sampling capabilities that allow servers to request LLM sampling from language models via clients. - -### Client Operations - -#### Root Management - -Both synchronous and asynchronous clients provide methods for managing filesystem roots: - -##### Synchronous API -```java -void addRoot(Root root) // Add a new root -void removeRoot(String rootUri) // Remove a root by URI -void rootsListChangedNotification() // Notify about roots list changes -``` - -##### Asynchronous API -```java -Mono addRoot(Root root) // Add a new root -Mono removeRoot(String rootUri) // Remove a root by URI -Mono rootsListChangedNotification() // Notify about roots list changes -``` - -#### Tools API - -Methods for interacting with server tools: - -##### Synchronous API -```java -CallToolResult callTool(CallToolRequest request) // Execute a tool -ListToolsResult listTools() // List all available tools -ListToolsResult listTools(String cursor) // List tools with pagination -``` - -##### Asynchronous API -```java -Mono callTool(CallToolRequest request) -Mono listTools() -Mono listTools(String cursor) -``` - -#### Resources API - -Methods for working with server resources: - -##### Synchronous API -```java -ListResourcesResult listResources() // List all resources -ListResourcesResult listResources(String cursor) // List resources with pagination -ReadResourceResult readResource(Resource resource) // Read a resource -ReadResourceResult readResource(ReadResourceRequest request) // Read a resource by request -ListResourceTemplatesResult listResourceTemplates() // List resource templates -void subscribeResource(SubscribeRequest request) // Subscribe to resource updates -void unsubscribeResource(UnsubscribeRequest request) // Unsubscribe from updates -void sendResourcesListChanged() // Notify about resource changes -``` - -##### Asynchronous API -```java -Mono listResources() -Mono listResources(String cursor) -Mono readResource(Resource resource) -Mono readResource(ReadResourceRequest request) -Mono listResourceTemplates() -Mono subscribeResource(SubscribeRequest request) -Mono unsubscribeResource(UnsubscribeRequest request) -Mono sendResourcesListChanged() -``` - -#### Prompts API - -Methods for working with server prompts: - -##### Synchronous API -```java -ListPromptsResult listPrompts() // List all prompts -ListPromptsResult listPrompts(String cursor) // List prompts with pagination -GetPromptResult getPrompt(GetPromptRequest request) // Get a specific prompt -void promptListChangedNotification() // Notify about prompt changes -``` - -##### Asynchronous API -```java -Mono listPrompts() -Mono listPrompts(String cursor) -Mono getPrompt(GetPromptRequest request) -Mono promptListChangedNotification() -``` - -#### Lifecycle Management - -Methods for managing client lifecycle: - -##### Synchronous API -```java -InitializeResult initialize() // Initialize client connection -void close() // Close client immediately -boolean closeGracefully() // Close client with graceful shutdown -Object ping() // Send ping request -``` - -##### Asynchronous API -```java -Mono initialize() -void close() -Mono closeGracefully() -Mono ping() -``` - -### Implementation Notes - -- The client factory uses a builder pattern for configuration -- Default request timeout is 20 seconds -- Multiple consumers can be registered for tools, resources, and prompts changes -- The builder validates that the transport is not null -- Deprecated static factory methods are available but using the builder pattern is recommended -- The synchronous client is implemented as a wrapper around the asynchronous client -- ClientCapabilities uses a builder pattern for configuring client features -- The synchronous client blocks on async operations with the configured request timeout -- Resource subscriptions require server support for the subscribe capability -- Root management requires client to be configured with root capabilities diff --git a/docs/ref-errors.md b/docs/ref-errors.md deleted file mode 100644 index 14d37c9..0000000 --- a/docs/ref-errors.md +++ /dev/null @@ -1,154 +0,0 @@ -# MCP Error Handling Reference - -## McpError Class - -`org.springframework.ai.mcp.spec.McpError` - -The `McpError` class is a runtime exception that represents errors that occur during Model Context Protocol (MCP) operations. It extends `RuntimeException` and provides a way to encapsulate error information from the MCP protocol. - -### Overview - -The McpError class is designed to handle various error scenarios that can occur during MCP communication, including protocol errors, transport errors, and application-specific errors. - -### Class Definition - -```java -public class McpError extends RuntimeException { - public McpError(Object error) { - super(error.toString()); - } -} -``` - -### Common Error Scenarios - -1. **Transport Errors** - - Connection failures - - Timeout errors - - Protocol violations - -2. **Protocol Errors** - - Invalid JSON-RPC messages - - Missing required fields - - Invalid method names - -3. **Application Errors** - - Tool execution failures - - Resource access errors - - Invalid parameters - -### Error Handling Best Practices - -#### Client-Side Error Handling - -```java -McpClient client = McpClient.using(transport) - .async(); - -client.sendRequest("method", params) - .onErrorResume(McpError.class, error -> { - // Handle MCP-specific errors - logger.error("MCP error occurred: {}", error.getMessage()); - return Mono.empty(); - }) - .onErrorResume(Exception.class, error -> { - // Handle other exceptions - logger.error("Unexpected error: {}", error.getMessage()); - return Mono.empty(); - }); -``` - -#### Server-Side Error Handling - -```java -McpServer.using(transport) - .tool(new McpSchema.Tool("example", "Example tool", inputSchema), - args -> { - try { - // Tool implementation - return new McpSchema.CallToolResult(result); - } - catch (Exception e) { - // Convert to MCP error - throw new McpError("Tool execution failed: " + e.getMessage()); - } - }) - .async(); -``` - -### Implementation Notes - -- McpError is a runtime exception, so it doesn't require explicit declaration in method signatures -- The error message is created by calling toString() on the provided error object -- Error handling should be implemented at both client and server sides -- Reactive error handling using Project Reactor's error operators is recommended -- Consider logging errors for debugging and monitoring purposes - -### Error Recovery Strategies - -1. **Retry Logic** -```java -client.sendRequest("method", params) - .retry(3) // Retry up to 3 times - .onErrorResume(McpError.class, error -> { - // Handle failure after retries - return Mono.empty(); - }); -``` - -2. **Fallback Values** -```java -client.sendRequest("method", params) - .onErrorReturn(McpError.class, fallbackValue); -``` - -3. **Circuit Breaking** -```java -// Using Resilience4j or similar library -CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("mcpClient"); -client.sendRequest("method", params) - .transform(CircuitBreakerOperator.of(circuitBreaker)); -``` - -### Error Logging and Monitoring - -```java -client.sendRequest("method", params) - .doOnError(McpError.class, error -> { - // Log error details - logger.error("MCP error: {}", error.getMessage()); - // Update metrics - errorCounter.increment(); - }); -``` - -### Integration with Spring Error Handling - -```java -@ControllerAdvice -public class McpErrorHandler { - @ExceptionHandler(McpError.class) - public ResponseEntity handleMcpError(McpError error) { - ErrorResponse response = new ErrorResponse( - "MCP_ERROR", - error.getMessage() - ); - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(response); - } -} -``` - -### Testing Error Scenarios - -```java -@Test -void testMcpError() { - McpError error = new McpError("Test error"); - assertEquals("Test error", error.getMessage()); - - assertThrows(McpError.class, () -> { - throw new McpError("Test error"); - }); -} diff --git a/docs/ref-index.md b/docs/ref-index.md deleted file mode 100644 index 13c860a..0000000 --- a/docs/ref-index.md +++ /dev/null @@ -1,91 +0,0 @@ -# Spring AI MCP Reference Documentation - -This documentation covers the Spring AI implementation of the Model Context Protocol (MCP) specification. The implementation follows the [official MCP specification](https://spec.modelcontextprotocol.io/specification/). - -## Component Hierarchy - -### 1. Transport Layer -[Transport](ref-transport.md) -- McpTransport Interface -- Transport Implementations (SSE, STDIO) -- Message Serialization/Deserialization - -### 2. Session Layer -[Session Management](ref-session.md) -- McpSession Interface -- DefaultMcpSession Implementation -- Uses McpTransport for communication - -### 3. High-Level Components -Both components operate at the same level, using McpSession internally: - -[Client](ref-client.md) -- McpClient Factory -- Synchronous and Asynchronous Implementations -- Client Configuration and Builder Pattern - -[Server](ref-server.md) -- McpServer Factory -- Synchronous and Asynchronous Implementations -- Server Configuration and Builder Pattern - -### 4. Supporting Components - -[Error Handling](ref-errors.md) -- McpError -- Error Codes and Handling -- Exception Hierarchy - - -## Component Relationships - -``` -┌─────────────┐ ┌─────────────┐ -│ McpClient │ │ McpServer │ -└─────────────┘ └─────────────┘ - │ │ - └─────────┬────────┘ - │ - ┌───────────────┐ - │ McpSession │ - └───────────────┘ - │ - ┌───────────────┐ - │ McpTransport │ - └───────────────┘ -``` - -## Package Organization - -The implementation is organized under the `org.springframework.ai.mcp` package with the following structure: - -- `org.springframework.ai.mcp.spec` - Core interfaces and classes - - McpTransport - - McpSession - - McpSchema - - McpError -- `org.springframework.ai.mcp.client` - Client implementations - - McpClient - - McpAsyncClient - - McpSyncClient - - Client-specific transports -- `org.springframework.ai.mcp.server` - Server implementations - - McpServer - - McpAsyncServer - - McpSyncServer - - Server-specific transports -- `org.springframework.ai.mcp.util` - Utility classes - - Assert - - Utils - -## Key Features - -- Synchronous and Asynchronous implementations -- Multiple transport options (SSE, STDIO) -- Comprehensive error handling -- Builder pattern for configuration -- Reactive programming support with Project Reactor -- Type-safe message handling -- Resource and prompt management -- Tool discovery and execution -- Change notification system diff --git a/docs/ref-server.md b/docs/ref-server.md deleted file mode 100644 index 5ee6739..0000000 --- a/docs/ref-server.md +++ /dev/null @@ -1,214 +0,0 @@ -# MCP Server Reference - -## McpServer Factory - -`org.springframework.ai.mcp.server.McpServer` - -The `McpServer` class serves as a factory for creating Model Context Protocol (MCP) servers. It provides the main entry point for establishing MCP servers, offering both synchronous and asynchronous server implementations. - -### Overview - -The server factory uses a builder pattern for flexible configuration, allowing you to customize various aspects of the server including server information, capabilities, tools, resource providers, and prompt providers. - -### Server Types - -The factory can create two types of servers: -- `McpAsyncServer` for non-blocking operations -- `McpSyncServer` for blocking operations - -### Builder Configuration - -#### Basic Usage -```java -McpServer.using(transport) - .serverInfo("my-server", "1.0.0") - .addTool(new MyToolHandler()) - .async(); // or .sync() -``` - -#### Builder Methods - -##### using(McpTransport transport) -```java -public static Builder using(McpTransport transport) -``` -Start building an MCP server with the specified transport. - -##### serverInfo(Implementation serverInfo) -```java -public Builder serverInfo(McpSchema.Implementation serverInfo) -``` -Set the server implementation information. - -##### serverInfo(String name, String version) -```java -public Builder serverInfo(String name, String version) -``` -Set the server implementation information using name and version. - -##### serverCapabilities(ServerCapabilities capabilities) -```java -public Builder serverCapabilities(McpSchema.ServerCapabilities serverCapabilities) -``` -Set the server capabilities configuration. - -##### addTool(ToolHandler handler) -```java -public Builder addTool(T toolHandler) -``` -Add a tool handler to the server. - -##### tool(Tool tool, Function, CallToolResult> handler) -```java -public Builder tool(McpSchema.Tool tool, Function, McpSchema.CallToolResult> handler) -``` -Add a tool with its handler function. - -##### addTools(List handlers) -```java -public Builder addTools(List toolHandlers) -``` -Add multiple tool handlers to the server. - -##### resourcesProvider(Function> provider) -```java -public Builder resourcesProvider(Function> resourcesProvider) -``` -Set the resources provider function. - -##### promptsProvider(Function> provider) -```java -public Builder promptsProvider(Function> promptsProvider) -``` -Set the prompts provider function. - -##### sync() -```java -public McpSyncServer sync() -``` -Build and return a synchronous MCP server. - -##### async() -```java -public AsyncServerBuilder async() -``` -Build and return an asynchronous server builder. - -### Usage Examples - -#### Creating a Basic Server -```java -McpTransport transport = // obtain transport instance -McpServer.using(transport) - .serverInfo("my-server", "1.0.0") - .sync(); -``` - -#### Creating a Server with Tools -```java -McpServer.using(transport) - .serverInfo("calculation-server", "1.0.0") - .tool( - new McpSchema.Tool("calculate", "Performs calculations", Map.of( - "operation", Map.of("type", "string"), - "numbers", Map.of("type", "array") - )), - args -> { - // Handle calculation - return new McpSchema.CallToolResult(/* result */); - } - ) - .async(); -``` - -#### Creating a Server with Resources and Prompts -```java -McpServer.using(transport) - .serverInfo("content-server", "1.0.0") - .resourcesProvider(cursor -> { - // Return list of resources based on cursor - return List.of(/* resources */); - }) - .promptsProvider(cursor -> { - // Return list of prompts based on cursor - return List.of(/* prompts */); - }) - .sync(); -``` - -- Tool handlers must be thread-safe as they may be called concurrently -- The synchronous server is implemented as a wrapper around the asynchronous server -- Resource and prompt providers use cursor-based pagination -- The AsyncServerBuilder allows adding tools after server creation - -### Tool Handler Interface - -```java -public interface ToolHandler { - String getName(); - String getDescription(); - Map getInputSchema(); - McpSchema.CallToolResult call(Map arguments); -} -``` - -Tool handlers implement this interface to define: -- Tool name and description -- Input schema for validation -- Execution logic in the call method - -### Logging Capabilities - -The MCP Server supports logging functionality that allows sending log messages with different severity levels to the Client. This feature can be enabled through server capabilities configuration. - -#### Enabling Logging - -```java -McpServer.using(transport) - .serverInfo("my-server", "1.0.0") - .capabilities(ServerCapabilities.builder().logging().build()) - .sync(); // or .async() -``` - -#### Logging Levels - -The server supports the following logging levels: -- TRACE -- DEBUG -- INFO -- WARN -- ERROR -- FATAL - -#### Sending Log Messages - -For synchronous servers: -```java -LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.INFO) - .logger("my-logger") - .data("Log message") - .build(); - -mcpSyncServer.loggingNotification(notification); -``` - -For asynchronous servers: -```java -LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.INFO) - .logger("my-logger") - .data("Log message") - .build(); - -mcpAsyncServer.loggingNotification(notification) - .subscribe(); -``` - -- Logging works even if the logging capability is not enabled -- Null notifications are rejected with a McpError -- Log messages include: - - Level: The severity level of the message - - Logger: The name of the logger (typically identifies the source) - - Data: The actual log message content -- The server implementation is transport-agnostic, allowing different transport mechanisms to handle the log messages diff --git a/docs/ref-session.md b/docs/ref-session.md deleted file mode 100644 index 8896dae..0000000 --- a/docs/ref-session.md +++ /dev/null @@ -1,116 +0,0 @@ -# MCP Session Reference - -## McpSession Interface - -`org.springframework.ai.mcp.spec.McpSession` - -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 foundation for request-response patterns and notification handling, as well as managing the session lifecycle. - -### Overview - -The session operates asynchronously using Project Reactor's `Mono` type for non-blocking operations. It supports both request-response patterns and one-way notifications. - -### Methods - -#### Request-Response - -```java - Mono sendRequest(String method, Object requestParams, TypeReference typeRef) -``` - -Sends a request to the model server and expects a response of type T. - -**Parameters:** -- `method`: The name of the method to be called on the server -- `requestParams`: The parameters to be sent with the request -- `typeRef`: The TypeReference describing the expected response type - -**Returns:** -- A `Mono` that will emit the response when received - -#### Notification - -```java -Mono sendNotification(String method, Map params) -``` - -Sends a notification to the model server with parameters. - -**Parameters:** -- `method`: The name of the notification method to be called on the server -- `params`: A map of parameters to be sent with the notification - -**Returns:** -- A `Mono` that completes when the notification has been sent - -#### Close Gracefully -```java -Mono closeGracefully() -``` - -Closes the session and releases any associated resources asynchronously. - -**Returns:** -- A `Mono` that completes when the session has been closed - -#### Close -```java -void close() -``` - -Closes the session and releases any associated resources synchronously. - -### 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 handle(Object params); -} - -public interface NotificationHandler { - Mono handle(Object params); -} -``` - - -### Usage Example - -```java -McpSession session = // obtain session instance - -// Send a request -TypeReference typeRef = new TypeReference() {}; -Mono response = session.sendRequest("method.name", requestParams, typeRef); - -// Send a notification without parameters -Mono notificationResult = session.sendNotification("notification.method"); - -// Send a notification with parameters -Map params = new HashMap<>(); -params.put("key", "value"); -Mono paramNotificationResult = session.sendNotification("notification.method", params); - -// Close the session gracefully -session.closeGracefully() - .subscribe( - null, - error -> System.err.println("Error closing session: " + error), - () -> System.out.println("Session closed successfully") - ); -``` - -### Implementation Notes - -- The interface is designed to be transport-agnostic, allowing different transport implementations (SSE, STDIO) to be used. -- All operations except `close()` are non-blocking and return `Mono` instances. -- The `sendRequest` method uses Jackson's `TypeReference` for type-safe deserialization of responses. -- The default implementation of `sendNotification(String)` delegates to `sendNotification(String, Map)` with null parameters. diff --git a/docs/ref-transport.md b/docs/ref-transport.md deleted file mode 100644 index dd21da7..0000000 --- a/docs/ref-transport.md +++ /dev/null @@ -1,124 +0,0 @@ -# MCP Transport Reference - -## McpTransport Interface - -`org.springframework.ai.mcp.spec.McpTransport` - -The `McpTransport` interface defines the asynchronous transport layer for the Model Context Protocol (MCP). It provides the foundation for implementing custom transport mechanisms, handling bidirectional communication between client and server components using JSON-RPC format. - -### Overview - -The transport layer is designed to be protocol-agnostic, allowing for various implementations such as WebSocket, HTTP, or custom protocols. It manages the lifecycle of connections and handles message exchange between components. - -### Key Responsibilities - -- Managing the lifecycle of the transport connection -- Handling incoming messages and errors from the server -- Sending outbound messages to the server -- Message serialization and deserialization - -### Methods - -#### Connect -```java -Mono connect(Function, Mono> handler) -``` - -Initializes and starts the transport connection. - -**Parameters:** -- `handler`: A function that processes incoming messages and returns response messages - -**Returns:** -- A `Mono` that completes when the connection is established - -#### Send Message -```java -Mono sendMessage(JSONRPCMessage message) -``` - -Sends a message to the server asynchronously. - -**Parameters:** -- `message`: The JSON-RPC message to be sent to the server - -**Returns:** -- A `Mono` that completes when the message has been sent - -#### Close Gracefully -```java -Mono closeGracefully() -``` - -Closes the transport connection and releases any associated resources asynchronously. - -**Returns:** -- A `Mono` that completes when the connection has been closed - -#### Close -```java -default void close() -``` - -Synchronously closes the transport connection. By default, this method delegates to `closeGracefully()` and subscribes to the result. - -#### Unmarshal From -```java - T unmarshalFrom(Object data, TypeReference typeRef) -``` - -Deserializes data into the specified type. - -**Parameters:** -- `data`: The data to deserialize -- `typeRef`: The TypeReference describing the target type - -**Returns:** -- The deserialized object of type T - -### Implementation Notes - -- The transport layer uses Project Reactor's `Mono` type for non-blocking operations -- Messages are exchanged using the JSON-RPC format as specified by the MCP protocol -- The interface is designed to be extensible, allowing for different transport implementations -- Current implementations include: - - SSE (Server-Sent Events) Transport - - STDIO Transport (Standard Input/Output) - -### Usage Example - -```java -McpTransport transport = // obtain transport instance - -// Connect and handle messages -transport.connect(message -> { - // Process incoming message - return processMessage(message); -}).subscribe(); - -// Send a message -JSONRPCMessage message = new JSONRPCMessage(); -// ... configure message -transport.sendMessage(message) - .subscribe( - null, - error -> System.err.println("Error sending message: " + error), - () -> System.out.println("Message sent successfully") - ); - -// Close the transport -transport.closeGracefully() - .subscribe( - null, - error -> System.err.println("Error closing transport: " + error), - () -> System.out.println("Transport closed successfully") - ); -``` - -### Transport Implementations - -#### SSE Transport -The SSE (Server-Sent Events) transport implementation provides a unidirectional communication channel from the server to the client using HTTP streaming. - -#### STDIO Transport -The STDIO transport implementation uses standard input/output streams for communication, making it suitable for command-line applications and process-based communication. diff --git a/mcp-docs/src/main/antora/modules/ROOT/images/class-diagrams.puml b/mcp-docs/src/main/antora/modules/ROOT/images/class-diagrams.puml index 7a2dfe2..92812df 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/images/class-diagrams.puml +++ b/mcp-docs/src/main/antora/modules/ROOT/images/class-diagrams.puml @@ -149,16 +149,27 @@ class StdioClientTransport implements ClientMcpTransport { +Sinks.Many getErrorSink() } -class SseClientTransport implements ClientMcpTransport { -} - class StdioServerTransport implements ServerMcpTransport { } -class SseServerTransport implements ServerMcpTransport { + +class HttpClientSseClientTransport implements ClientMcpTransport { +} + + +class WebFluxSseClientTransport implements ClientMcpTransport { +} + + +class WebFluxSseServerTransport implements ServerMcpTransport { +RouterFunction getRouterFunction() } +class WebMvcSseServerTransport implements ServerMcpTransport { + +RouterFunction getRouterFunction() +} + + ' Schema and Error Classes class McpSchema { +class ErrorCodes diff --git a/mcp-docs/src/main/antora/modules/ROOT/images/spring-ai-mcp-uml-classdiagram.svg b/mcp-docs/src/main/antora/modules/ROOT/images/spring-ai-mcp-uml-classdiagram.svg index ecf2b64..1843faa 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/images/spring-ai-mcp-uml-classdiagram.svg +++ b/mcp-docs/src/main/antora/modules/ROOT/images/spring-ai-mcp-uml-classdiagram.svg @@ -1 +1 @@ -McpTransportMono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler)Mono<Void> sendMessage(JSONRPCMessage message)void close()Mono<Void> closeGracefully()<T> T unmarshalFrom(Object data, TypeReference<T> typeRef)McpSession<T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef)Mono<Void> sendNotification(String method, Map<String, Object> params)Mono<Void> closeGracefully()void close()DefaultMcpSessioninterface RequestHandlerinterface NotificationHandlerMcpClientBuilder using(ClientMcpTransport transport)McpAsyncClientMono<InitializeResult> initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully()Mono<Object> ping()Mono<Void> addRoot(Root root)Mono<Void> removeRoot(String rootUri)Mono<Void> rootsListChangedNotification()Mono<CallToolResult> callTool(CallToolRequest request)Mono<ListToolsResult> listTools()Mono<ListResourcesResult> listResources()Mono<ReadResourceResult> readResource(ReadResourceRequest request)Mono<ListResourceTemplatesResult> listResourceTemplates()Mono<Void> subscribeResource(SubscribeRequest request)Mono<Void> unsubscribeResource(UnsubscribeRequest request)Mono<ListPromptsResult> listPrompts()Mono<GetPromptResult> getPrompt(GetPromptRequest request)Mono<Void> setLoggingLevel(LoggingLevel level)McpSyncClientInitializeResult initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()boolean closeGracefully()Object ping()void addRoot(Root root)void removeRoot(String rootUri)void rootsListChangedNotification()CallToolResult callTool(CallToolRequest request)ListToolsResult listTools()ListResourcesResult listResources()ReadResourceResult readResource(ReadResourceRequest request)ListResourceTemplatesResult listResourceTemplates()void subscribeResource(SubscribeRequest request)void unsubscribeResource(UnsubscribeRequest request)ListPromptsResult listPrompts()GetPromptResult getPrompt(GetPromptRequest request)void setLoggingLevel(LoggingLevel level)McpServerBuilder using(ServerMcpTransport transport)McpAsyncServerServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully() Mono<Void> addTool(ToolRegistration toolRegistration)Mono<Void> removeTool(String toolName)Mono<Void> notifyToolsListChanged() Mono<Void> addResource(ResourceRegistration resourceHandler)Mono<Void> removeResource(String resourceUri)Mono<Void> notifyResourcesListChanged() Mono<Void> addPrompt(PromptRegistration promptRegistration)Mono<Void> removePrompt(String promptName)Mono<Void> notifyPromptsListChanged() Mono<Void> loggingNotification(LoggingMessageNotification notification) Mono<CreateMessageResult> createMessage(CreateMessageRequest request)McpSyncServerMcpAsyncServer getAsyncServer() ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()void closeGracefully() void addTool(ToolRegistration toolHandler)void removeTool(String toolName)void notifyToolsListChanged() void addResource(ResourceRegistration resourceHandler)void removeResource(String resourceUri)void notifyResourcesListChanged() void addPrompt(PromptRegistration promptRegistration)void removePrompt(String promptName)void notifyPromptsListChanged() void loggingNotification(LoggingMessageNotification notification) CreateMessageResult createMessage(CreateMessageRequest request)StdioClientTransportvoid setErrorHandler(Consumer<String> errorHandler)Sinks.Many<String> getErrorSink()ClientMcpTransportSseClientTransportStdioServerTransportServerMcpTransportSseServerTransportRouterFunction<?> getRouterFunction()McpSchemaclass ErrorCodesinterface Requestinterface JSONRPCMessageinterface ResourceContentsinterface Contentinterface ServerCapabilitiesJSONRPCMessage deserializeJsonRpcMessage()McpErrorcreatescreatesdelegates tocreatescreatesusesthrows \ No newline at end of file +McpTransportMono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler)Mono<Void> sendMessage(JSONRPCMessage message)void close()Mono<Void> closeGracefully()<T> T unmarshalFrom(Object data, TypeReference<T> typeRef)McpSession<T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef)Mono<Void> sendNotification(String method, Map<String, Object> params)Mono<Void> closeGracefully()void close()DefaultMcpSessioninterface RequestHandlerinterface NotificationHandlerMcpClientBuilder using(ClientMcpTransport transport)McpAsyncClientMono<InitializeResult> initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully()Mono<Object> ping()Mono<Void> addRoot(Root root)Mono<Void> removeRoot(String rootUri)Mono<Void> rootsListChangedNotification()Mono<CallToolResult> callTool(CallToolRequest request)Mono<ListToolsResult> listTools()Mono<ListResourcesResult> listResources()Mono<ReadResourceResult> readResource(ReadResourceRequest request)Mono<ListResourceTemplatesResult> listResourceTemplates()Mono<Void> subscribeResource(SubscribeRequest request)Mono<Void> unsubscribeResource(UnsubscribeRequest request)Mono<ListPromptsResult> listPrompts()Mono<GetPromptResult> getPrompt(GetPromptRequest request)Mono<Void> setLoggingLevel(LoggingLevel level)McpSyncClientInitializeResult initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()boolean closeGracefully()Object ping()void addRoot(Root root)void removeRoot(String rootUri)void rootsListChangedNotification()CallToolResult callTool(CallToolRequest request)ListToolsResult listTools()ListResourcesResult listResources()ReadResourceResult readResource(ReadResourceRequest request)ListResourceTemplatesResult listResourceTemplates()void subscribeResource(SubscribeRequest request)void unsubscribeResource(UnsubscribeRequest request)ListPromptsResult listPrompts()GetPromptResult getPrompt(GetPromptRequest request)void setLoggingLevel(LoggingLevel level)McpServerBuilder using(ServerMcpTransport transport)McpAsyncServerServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully() Mono<Void> addTool(ToolRegistration toolRegistration)Mono<Void> removeTool(String toolName)Mono<Void> notifyToolsListChanged() Mono<Void> addResource(ResourceRegistration resourceHandler)Mono<Void> removeResource(String resourceUri)Mono<Void> notifyResourcesListChanged() Mono<Void> addPrompt(PromptRegistration promptRegistration)Mono<Void> removePrompt(String promptName)Mono<Void> notifyPromptsListChanged() Mono<Void> loggingNotification(LoggingMessageNotification notification) Mono<CreateMessageResult> createMessage(CreateMessageRequest request)McpSyncServerMcpAsyncServer getAsyncServer() ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()void closeGracefully() void addTool(ToolRegistration toolHandler)void removeTool(String toolName)void notifyToolsListChanged() void addResource(ResourceRegistration resourceHandler)void removeResource(String resourceUri)void notifyResourcesListChanged() void addPrompt(PromptRegistration promptRegistration)void removePrompt(String promptName)void notifyPromptsListChanged() void loggingNotification(LoggingMessageNotification notification) CreateMessageResult createMessage(CreateMessageRequest request)StdioClientTransportvoid setErrorHandler(Consumer<String> errorHandler)Sinks.Many<String> getErrorSink()ClientMcpTransportStdioServerTransportServerMcpTransportHttpClientSseClientTransportWebFluxSseClientTransportWebFluxSseServerTransportRouterFunction<?> getRouterFunction()WebMvcSseServerTransportRouterFunction<?> getRouterFunction()McpSchemaclass ErrorCodesinterface Requestinterface JSONRPCMessageinterface ResourceContentsinterface Contentinterface ServerCapabilitiesJSONRPCMessage deserializeJsonRpcMessage()McpErrorcreatescreatesdelegates tocreatescreatesusesthrows \ No newline at end of file diff --git a/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc b/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc index d68726a..ef76335 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/mcp.adoc @@ -1,25 +1,35 @@ = Java MCP SDK -Java SDK implementation of the Model Context Protocol, enabling seamless integration with language models and AI tools. +Java SDK implementation of the link:https://modelcontextprotocol.io/introduction[Model Context Protocol], enabling seamless integration with language models and AI tools. == Features * Synchronous and Asynchronous MCP Client and MCP Server implementations * Standard MCP operations support: -** Protocol version compatibility negotiation -** Tool discovery, execution, list change notifications -** Resource management with URI templates -** Roots list management and notifications -** Prompt handling and management -** Smampling support for AI model interactions +** Protocol link:https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle/#initialization[version compatibility negotiation] +** link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/[Tool] discovery, execution, list change notifications +** link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/[Resource] management with URI templates +** link:https://spec.modelcontextprotocol.io/specification/2024-11-05/client/roots/[Roots] list management and notifications +** link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/[Prompt] handling and management +** link:https://spec.modelcontextprotocol.io/specification/2024-11-05/client/sampling/[Sampling] support for AI model interactions * Multiple transport implementations: -** Stdio-based transport for process-based communication -** SSE-based transport for HTTP streaming +** Core transports: +*** Stdio-based transport for process-based communication +*** Java HttpClient-based SSE transport for HTTP streaming +** Spring-based transports: +*** WebFlux SSE transport for reactive HTTP streaming +*** WebMVC SSE transport for servlet-based HTTP streaming === Dependencies Add the following dependency to your Maven project: +[tabs] +====== +Maven:: ++ +The core MCP functionality: ++ [source,xml] ---- @@ -28,6 +38,51 @@ Add the following dependency to your Maven project: 0.5.0-SNAPSHOT ---- ++ +For HTTP SSE transport implementations, add one of the following dependencies +(Note that you already have HTTP Client SSE client transport in the core module): ++ +[source,xml] +---- + + + org.springframework.experimental + mcp-webflux-sse-transport + 0.5.0-SNAPSHOT + + + + + org.springframework.experimental + mcp-webmvc-sse-transport + 0.5.0-SNAPSHOT + +---- + +Gradle:: ++ +The core MCP functionality: ++ +[source,groovy] +---- +dependencies { + implementation 'org.springframework.experimental:mcp' +} +---- ++ +For HTTP SSE transport implementations, add one of the following dependencies +(note that you already have HTTP Client SSE client transport in the core module): ++ +[source,groovy] +---- +// Spring WebFlux-based SSE client and server transport +implementation 'org.springframework.experimental:mcp-webflux-sse-transport' + +// Spring WebMVC-based SSE server transport +implementation 'org.springframework.experimental:mcp-webmvc-sse-transport' +---- +====== + == Architecture @@ -37,7 +92,9 @@ The SDK follows a layered architecture with clear separation of concerns: * *Client/Server Layer*: Both use McpSession for sync/async operations, with McpClient handling client-side protocol operations and McpServer managing server-side protocol operations. * *Session Layer (McpSession)*: Manages communication patterns and state using DefaultMcpSession implementation. -* *Transport Layer (McpTransport)*: Handles JSON-RPC message serialization/deserialization via StdioTransport (stdin/stdout) and SseTransport (HTTP streaming). +* *Transport Layer (McpTransport)*: Handles JSON-RPC message serialization/deserialization via: +** StdioTransport (stdin/stdout) in the core module +** HTTP SSE transports in dedicated transport modules (Java HttpClient, Spring WebFlux, Spring WebMVC) Following class diagram illustrates the layered architecture of the MCP SDK, showing the relationships between core interfaces (McpTransport, McpSession), their implementations, and the client/server components. It highlights how the transport layer connects to sessions, which in turn support both synchronous and asynchronous client/server implementations. @@ -187,23 +244,36 @@ The transport layer handles the communication between MCP clients and servers, p ====== STDIO:: + +Creates transport for in-process based communication ++ [source,java] ---- -// Create transport for process-based communication ServerParameters params = ServerParameters.builder("npx") .args("-y", "@modelcontextprotocol/server-everything", "dir") .build(); McpTransport transport = new StdioClientTransport(params); ---- - -SSE:: ++ +SSE (HttpClient):: ++ +Creates a framework agnostic (pure Java API) SSE client transport. +Included in the core `mcp` module. ++ +[source,java] +---- +McpTransport transport = new HttpClientSseClientTransport("http://your-mcp-server"); +---- ++ +SSE (WebFlux):: ++ +Creates WebFlux-based SSE client transport. +Requires the `mcp-webflux-sse-transport` dependency. + [source,java] ---- -// Create transport for HTTP streaming WebClient.Builder webClientBuilder = WebClient.builder() .baseUrl("http://your-mcp-server"); -McpTransport transport = new SseClientTransport(webClientBuilder); +McpTransport transport = new WebFluxSseClientTransport(webClientBuilder); ---- ====== @@ -381,29 +451,72 @@ The server transport layer implements the server-side communication protocols, e ====== STDIO:: + +Create in-process based transport ++ [source,java] ---- -// Create transport with custom ObjectMapper -ObjectMapper mapper = new ObjectMapper(); -StdioServerTransport transport = new StdioServerTransport(mapper); +StdioServerTransport transport = new StdioServerTransport(new ObjectMapper()); ---- + Provides bidirectional JSON-RPC message handling over standard input/output streams with non-blocking message processing, serialization/deserialization, and graceful shutdown support. -SSE:: +SSE (WebFlux):: ++ +Creates WebFlux-based SSE server transport. +Requires the `mcp-webflux-sse-transport` dependency. + [source,java] ---- -// Create SSE transport -ObjectMapper mapper = new ObjectMapper(); -String messageEndpoint = "/mcp/message"; -SseServerTransport transport = new SseServerTransport(mapper, messageEndpoint); +@Configuration +class McpConfig { + @Bean + WebFluxSseServerTransport webFluxSseServerTransport(ObjectMapper mapper) { + return new WebFluxSseServerTransport(mapper, "/mcp/message"); + } -// Get router function for web server configuration -RouterFunction router = transport.getRouterFunction(); + @Bean + RouterFunction mcpRouterFunction(WebFluxSseServerTransport transport) { + return transport.getRouterFunction(); + } +} ---- + -Implements the MCP HTTP with SSE transport specification, providing concurrent client connections through SSE endpoints with message routing, session management, and graceful shutdown capabilities. +Implements the MCP HTTP with SSE transport specification, providing: ++ +* Reactive HTTP streaming with WebFlux +* Concurrent client connections through SSE endpoints +* Message routing and session management +* Graceful shutdown capabilities + +SSE (WebMvc):: ++ +Creates WebMvc-based SSE server transport. +Requires the `mcp-webmvc-sse-transport` dependency. ++ +[source,java] +---- +@Configuration +@EnableWebMvc +class McpConfig { + @Bean + WebMvcSseServerTransport webMvcSseServerTransport(ObjectMapper mapper) { + return new WebMvcSseServerTransport(mapper, "/mcp/message"); + } + + @Bean + RouterFunction mcpRouterFunction(WebMvcSseServerTransport transport) { + return transport.getRouterFunction(); + } +} +---- ++ +Implements the MCP HTTP with SSE transport specification, providing: ++ +* Servlet-based HTTP streaming with Spring MVC +* Concurrent client connections through SSE endpoints +* Message routing and session management +* Graceful shutdown capabilities + ====== === Server Capabilities diff --git a/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc b/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc index 35cbecb..d3b66e9 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/overview.adoc @@ -13,19 +13,38 @@ There are two main projects in this repository: Java implementation of the Model Context Protocol specification. It includes: -* Synchronous and asynchronous link:https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/mcp/README.md#client-usage-examples[MCP Client] and link:https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/mcp/README.md#server-usage-examples[MCP Server] implementations -* Standard MCP operations support (tool discovery, resource management, prompt handling, structured logging). Support for request and notification handling. -* link:https://spec.modelcontextprotocol.io/specification/basic/transports/#stdio[Stdio] and link:https://spec.modelcontextprotocol.io/specification/basic/transports/#http-with-sse[SSE] transport implementations. +* Synchronous and asynchronous xref:mcp.adoc#mcp-client[MCP Client] and xref:mcp.adoc#mcp-server[MCP Server] implementations +* Standard MCP operations support: +** Tool discovery and execution +** Resource management with URI templates +** Prompt handling and management +** Structured logging +** Request and notification handling +* Multiple transport implementations: +** Core transports: +*** Stdio-based transport for process-based communication +*** Java HttpClient-based SSE transport for HTTP streaming +** Spring-based transports: +*** WebFlux SSE transport for reactive HTTP streaming +*** WebMVC SSE transport for servlet-based HTTP streaming + +==== WebFlux SSE Transport +The link:https://github.com/spring-projects-experimental/spring-ai-mcp/tree/main/mcp-transport/mcp-webflux-sse-transport[mcp-webflux-sse-transport] project provides WebFlux-based implementation for the SSE client and server transports, enabling reactive HTTP streaming with Spring WebFlux. + +==== WebMvc SSE Transport +The link:https://github.com/spring-projects-experimental/spring-ai-mcp/tree/main/mcp-transport/mcp-webmvc-sse-transport[mcp-webmvc-sse-transport] project provides WebMvc-based implementation for the SSE server transport. The core module's HttpClientSseClientTransport can be used for client-side communication. === xref:spring-mcp.adoc[Spring AI MCP] The Spring integration module provides Spring-specific functionality: * Integration with Spring AI's function calling system +* Bidirectional conversion between Spring AI function callbacks and MCP tools +* JSON schema generation for tool input validation +* Automatic type conversion and error handling * Spring-friendly abstractions for MCP clients * Auto-configurations (WIP) - == Installation Add the following dependencies to your Maven project: @@ -34,9 +53,10 @@ Add the following dependencies to your Maven project: ====== Maven:: + +The core MCP functionality: ++ [source,xml] ---- - org.springframework.experimental mcp @@ -44,9 +64,29 @@ Maven:: ---- + +For HTTP SSE transport implementations, add one or more of the following dependencies: ++ +[source,xml] +---- + + + org.springframework.experimental + mcp-webflux-sse-transport + 0.5.0-SNAPSHOT + + + + + org.springframework.experimental + mcp-webmvc-sse-transport + 0.5.0-SNAPSHOT + +---- ++ +For Spring AI integration: ++ [source,xml] ---- - org.springframework.experimental spring-ai-mcp @@ -55,31 +95,45 @@ Maven:: ---- + This is a milestone release, not available on Maven Central. -Add this repository to your POM: +Add the Spring milestone repository to your POM: + [source,xml] ---- - - spring-milestones - Spring Milestones - https://repo.spring.io/libs-milestone-local - - false - - + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + ---- Gradle:: + +The core MCP functionality: ++ [source,groovy] ---- dependencies { implementation 'org.springframework.experimental:mcp' } ---- -Or for Spring AI integration: ++ +For HTTP SSE transport implementations, add one or more of the following dependencies: ++ +[source,groovy] +---- +// Spring WebFlux-based SSE client and server transport +implementation 'org.springframework.experimental:mcp-webflux-sse-transport' + +// Spring WebMVC-based SSE server transport +implementation 'org.springframework.experimental:mcp-webmvc-sse-transport' +---- ++ +For Spring AI integration: + [source,groovy] ---- @@ -87,6 +141,16 @@ dependencies { implementation 'org.springframework.experimental:spring-ai-mcp' } ---- ++ +This is a milestone release, not available on Maven Central. +Add the Spring milestone repository to your build: ++ +[source,groovy] +---- +repositories { + maven { url 'https://repo.spring.io/milestone' } +} +---- ====== == Examples @@ -96,14 +160,14 @@ Explore these MCP examples in the link:https://github.com/spring-projects/spring * link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/sqlite/simple[SQLite Simple] - Demonstrates LLM integration with a database * link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/sqlite/chatbot[SQLite Chatbot] - Interactive chatbot with SQLite database interaction * link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/filesystem[Filesystem] - Enables LLM interaction with local filesystem folders and files -* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/brave[Brave] - Enables natural language interactions with Brave Search, allowing you to perform internet searches. -* link:./spring-ai-mcp-sample/[Spring-ai-mcp-sample] - Showcases how to create and use MCP servers and clients with different transport modes and capabilities. +* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/brave[Brave] - Enables natural language interactions with Brave Search +* link:https://github.com/spring-projects-experimental/spring-ai-mcp/tree/main/samples/spring-ai-mcp-sample[Spring-ai-mcp-sample] - Showcases MCP servers and clients with different transport modes and capabilities +* link:https://github.com/habuma/spring-ai-examples/tree/main/spring-ai-mcp[Theme Park API Example] - Shows how to create an MCP server and client with Spring AI, exposing Theme Park API tools == Documentation -* link:mcp/README.md[Java MCP SDK documentation] -** link:docs/ref-index.md[Reference documentation] -* link:spring-ai-mcp/README.md[Spring Integration documentation] +* xref:mcp.adoc[Java MCP SDK Documentation] +* xref:spring-mcp.adoc[Spring Integration Documentation] == Development diff --git a/mcp-docs/src/main/antora/modules/ROOT/pages/spring-mcp.adoc b/mcp-docs/src/main/antora/modules/ROOT/pages/spring-mcp.adoc index ec762d9..d1bd57e 100644 --- a/mcp-docs/src/main/antora/modules/ROOT/pages/spring-mcp.adoc +++ b/mcp-docs/src/main/antora/modules/ROOT/pages/spring-mcp.adoc @@ -14,18 +14,100 @@ The `spring-ai-mcp` module is part of the https://github.com/spring-projects-exp == Features * Spring integration for MCP clients -* Function callback implementation for tool invocations -* Seamless integration with Spring AI's function calling capabilities +* Bidirectional conversion between Spring AI function callbacks and MCP tools +* JSON schema generation for tool input validation +* Automatic type conversion and error handling +* Integration with Spring AI's function calling capabilities == Main Components === McpFunctionCallback -The `McpFunctionCallback` class is the primary component of this module. It implements Spring AI's `FunctionCallback` interface and provides: +The `McpFunctionCallback` class implements Spring AI's `FunctionCallback` interface and provides integration between Spring AI's function calling system and MCP tools. Key features include: -* Integration between Spring AI's function calling system and MCP tools * Automatic conversion between JSON and Java objects for tool arguments * Synchronous tool execution support +* Error handling and result formatting +* Integration with Spring AI's function calling system + +Example usage: + +[source,java] +---- +// Create an MCP client +McpSyncClient mcpClient = McpClient.using(transport) + .sync(); + +// Create a function callback for an MCP tool +Tool calculatorTool = new Tool("calculator", "Basic calculator", + Map.of("operation", "string", "a", "number", "b", "number")); +FunctionCallback callback = new McpFunctionCallback(mcpClient, calculatorTool); + +// Use the callback with Spring AI +String result = callback.call("{\"operation\": \"add\", \"a\": 2, \"b\": 3}"); +---- + +=== ToolHelper + +The `ToolHelper` utility class facilitates the integration between Spring AI's function callbacks and MCP's tool system. It provides methods for: + +* Converting Spring AI's `FunctionCallback` instances to MCP tool registrations +* Generating JSON schemas for tool input validation +* Handling error cases and result formatting + +Example usage: + +[source,java] +---- +// Convert Spring AI function callbacks to MCP tool registrations +List callbacks = List.of( + new CalculatorFunction(), + new WeatherFunction() +); +List tools = ToolHelper.toToolRegistration(callbacks); + +// Generate JSON schema for tool inputs +Map> inputTypes = Map.of( + "calculator", CalculatorInput.class, + "weather", WeatherInput.class +); +String schema = ToolHelper.generateJsonSchema(inputTypes); +---- + +==== Converting Function Callbacks to Tools + +The `ToolHelper` provides several methods to convert Spring AI function callbacks to MCP tools: + +[source,java] +---- +// Convert a single function callback +ToolRegistration tool = ToolHelper.toToolRegistration(myCallback); + +// Convert multiple callbacks +List tools = ToolHelper.toToolRegistration(callback1, callback2); + +// Convert a list of callbacks +List tools = ToolHelper.toToolRegistration(callbackList); +---- + +==== JSON Schema Generation + +The `ToolHelper` can generate JSON schemas for tool input validation: + +[source,java] +---- +// Using default ObjectMapper +String schema = ToolHelper.generateJsonSchema(inputTypes); + +// Using custom ObjectMapper +ObjectMapper mapper = new ObjectMapper(); +String schema = ToolHelper.generateJsonSchema(inputTypes, mapper); +---- + +The generated schema follows the JSON Schema Draft 2020-12 specification and: +* Validates the structure of tool inputs +* Excludes ToolContext class from schema generation +* Uses Jackson's JsonSchemaGenerator for accurate type representation == Usage @@ -39,3 +121,41 @@ To use this module, add the following dependency to your Maven project: 0.5.0-SNAPSHOT ---- + +=== Example: Creating an MCP Tool Server with Spring AI Functions + +[source,java] +---- +@Configuration +class McpConfig { + + @Bean + McpServer mcpServer(List callbacks) { + // Convert Spring AI callbacks to MCP tools + List tools = ToolHelper.toToolRegistration(callbacks); + + return McpServer.using(transport) + .info("spring-ai-server", "1.0.0") + .tools(tools) + .sync(); + } + + @Bean + FunctionCallback calculatorFunction() { + return FunctionCallback.builder() + .name("calculator") + .description("Basic calculator") + .function(input -> { + // Function implementation + return result; + }) + .build(); + } +} +---- + +This configuration: +1. Creates Spring AI function callbacks +2. Converts them to MCP tools using ToolHelper +3. Registers the tools with an MCP server +4. Makes the tools available for discovery and execution by MCP clients diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml new file mode 100644 index 0000000..f3aa5db --- /dev/null +++ b/mcp-test/pom.xml @@ -0,0 +1,101 @@ + + + 4.0.0 + + org.springframework.experimental + mcp-parent + 0.5.0-SNAPSHOT + + mcp-test + jar + Tests for the Java MCP SDK + Provides some shared test fasilities for the MCP Java SDK + https://github.com/spring-projects-experimental/spring-ai-mcp + + + https://github.com/spring-projects-experimental/spring-ai-mcp + git://github.com/spring-projects-experimental/spring-ai-mcp.git + git@github.com:spring-projects-experimental/spring-ai-mcp.git + + + + + org.springframework.experimental + mcp + 0.5.0-SNAPSHOT + + + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + io.projectreactor + reactor-core + + + + org.assertj + assertj-core + ${assert4j.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + + + org.mockito + mockito-core + ${mockito.version} + + + io.projectreactor + reactor-test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + + + + org.awaitility + awaitility + 4.2.0 + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + + + + \ No newline at end of file diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/MockMcpTransport.java b/mcp-test/src/main/java/org/springframework/ai/mcp/MockMcpTransport.java new file mode 100644 index 0000000..20736b1 --- /dev/null +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/MockMcpTransport.java @@ -0,0 +1,103 @@ +/* + * 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; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +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.ClientMcpTransport; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.JSONRPCNotification; +import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest; +import org.springframework.ai.mcp.spec.ServerMcpTransport; + +@SuppressWarnings("unused") +public class MockMcpTransport implements ClientMcpTransport, ServerMcpTransport { + + private final AtomicInteger inboundMessageCount = new AtomicInteger(0); + + private final Sinks.Many outgoing = Sinks.many().multicast().onBackpressureBuffer(); + + private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer(); + + private final Flux 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 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(); + } + + private volatile boolean connected = false; + + @Override + public Mono connect(Function, Mono> handler) { + if (connected) { + return Mono.error(new IllegalStateException("Already connected")); + } + connected = true; + return inbound.asFlux() + .publishOn(Schedulers.boundedElastic()) + .flatMap(message -> Mono.just(message).transform(handler)) + .doFinally(signal -> connected = false) + .then(); + } + + @Override + public Mono closeGracefully() { + return Mono.defer(() -> { + connected = false; + outgoing.tryEmitComplete(); + inbound.tryEmitComplete(); + return Mono.empty(); + }); + } + + @Override + public T unmarshalFrom(Object data, TypeReference typeRef) { + return new ObjectMapper().convertValue(data, typeRef); + } + +} \ No newline at end of file diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpAsyncClientTests.java new file mode 100644 index 0000000..9477f6b --- /dev/null +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpAsyncClientTests.java @@ -0,0 +1,373 @@ +/* + * 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.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import org.springframework.ai.mcp.spec.ClientMcpTransport; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest; +import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities; +import org.springframework.ai.mcp.spec.McpSchema.CreateMessageRequest; +import org.springframework.ai.mcp.spec.McpSchema.CreateMessageResult; +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.SubscribeRequest; +import org.springframework.ai.mcp.spec.McpSchema.Tool; +import org.springframework.ai.mcp.spec.McpSchema.UnsubscribeRequest; +import org.springframework.ai.mcp.spec.McpTransport; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Test suite for the {@link McpAsyncClient} that can be used with different + * {@link McpTransport} implementations. + * + * @author Christian Tzolov + * @author Dariusz Jędrzejczyk + */ +public abstract class BlaAbstractMcpAsyncClientTests { + + private McpAsyncClient mcpAsyncClient; + + protected ClientMcpTransport mcpTransport; + + private static final Duration TIMEOUT = Duration.ofSeconds(20); + + private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!"; + + abstract protected ClientMcpTransport createMcpTransport(); + + protected void onStart() { + } + + protected void onClose() { + } + + @BeforeEach + void setUp() { + onStart(); + this.mcpTransport = createMcpTransport(); + + assertThatCode(() -> { + mcpAsyncClient = McpClient.using(mcpTransport) + .requestTimeout(TIMEOUT) + .capabilities(ClientCapabilities.builder().roots(true).build()) + .async(); + mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); + }).doesNotThrowAnyException(); + } + + @AfterEach + void tearDown() { + if (mcpAsyncClient != null) { + assertThatCode(() -> mcpAsyncClient.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + } + onClose(); + } + + @Test + void testConstructorWithInvalidArguments() { + assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Transport must not be null"); + + assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Request timeout must not be null"); + } + + @Test + void testListTools() { + StepVerifier.create(mcpAsyncClient.listTools(null)).consumeNextWith(result -> { + assertThat(result.tools()).isNotNull().isNotEmpty(); + + Tool firstTool = result.tools().get(0); + assertThat(firstTool.name()).isNotNull(); + assertThat(firstTool.description()).isNotNull(); + }).verifyComplete(); + } + + @Test + void testPing() { + assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException(); + } + + @Test + void testCallTool() { + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); + + StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> { + assertThat(callToolResult).isNotNull().satisfies(result -> { + assertThat(result.content()).isNotNull(); + assertThat(result.isError()).isNull(); + }); + }).verifyComplete(); + } + + @Test + void testCallToolWithInvalidTool() { + CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE)); + + assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class); + } + + @Test + void testListResources() { + StepVerifier.create(mcpAsyncClient.listResources(null)).consumeNextWith(resources -> { + assertThat(resources).isNotNull().satisfies(result -> { + assertThat(result.resources()).isNotNull(); + + if (!result.resources().isEmpty()) { + Resource firstResource = result.resources().get(0); + assertThat(firstResource.uri()).isNotNull(); + assertThat(firstResource.name()).isNotNull(); + } + }); + }).verifyComplete(); + } + + @Test + void testMcpAsyncClientState() { + assertThat(mcpAsyncClient).isNotNull(); + } + + @Test + void testListPrompts() { + StepVerifier.create(mcpAsyncClient.listPrompts(null)).consumeNextWith(prompts -> { + assertThat(prompts).isNotNull().satisfies(result -> { + assertThat(result.prompts()).isNotNull(); + + if (!result.prompts().isEmpty()) { + Prompt firstPrompt = result.prompts().get(0); + assertThat(firstPrompt.name()).isNotNull(); + assertThat(firstPrompt.description()).isNotNull(); + } + }); + }).verifyComplete(); + } + + @Test + void testGetPrompt() { + StepVerifier.create(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of()))) + .consumeNextWith(prompt -> { + assertThat(prompt).isNotNull().satisfies(result -> { + assertThat(result.messages()).isNotEmpty(); + assertThat(result.messages()).hasSize(1); + }); + }) + .verifyComplete(); + } + + @Test + void testRootsListChanged() { + assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException(); + } + + @Test + void testInitializeWithRootsListProviders() { + var transport = createMcpTransport(); + + var client = McpClient.using(transport) + .requestTimeout(TIMEOUT) + .roots(new Root("file:///test/path", "test-root")) + .async(); + + assertThatCode(() -> client.initialize().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + + assertThatCode(() -> client.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + } + + @Test + void testAddRoot() { + Root newRoot = new Root("file:///new/test/path", "new-test-root"); + assertThatCode(() -> mcpAsyncClient.addRoot(newRoot).block()).doesNotThrowAnyException(); + } + + @Test + void testAddRootWithNullValue() { + assertThatThrownBy(() -> mcpAsyncClient.addRoot(null).block()).hasMessageContaining("Root must not be null"); + } + + @Test + void testRemoveRoot() { + Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + assertThatCode(() -> { + mcpAsyncClient.addRoot(root).block(); + mcpAsyncClient.removeRoot(root.uri()).block(); + }).doesNotThrowAnyException(); + } + + @Test + void testRemoveNonExistentRoot() { + assertThatThrownBy(() -> mcpAsyncClient.removeRoot("nonexistent-uri").block()) + .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); + } + + @Test + @Disabled + void testReadResource() { + StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); + StepVerifier.create(mcpAsyncClient.readResource(firstResource)).consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).isNotNull(); + }).verifyComplete(); + } + }).verifyComplete(); + } + + @Test + void testListResourceTemplates() { + StepVerifier.create(mcpAsyncClient.listResourceTemplates()).consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).isNotNull(); + }).verifyComplete(); + } + + // @Test + void testResourceSubscription() { + StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); + + // Test subscribe + StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) + .verifyComplete(); + + // Test unsubscribe + StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) + .verifyComplete(); + } + }).verifyComplete(); + } + + @Test + void testNotificationHandlers() { + AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false); + AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); + AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); + + var transport = createMcpTransport(); + var client = McpClient.using(transport) + .requestTimeout(TIMEOUT) + .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) + .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) + .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) + .async(); + + assertThatCode(() -> { + client.initialize().block(); + // Trigger notifications + client.sendResourcesListChanged().block(); + client.promptListChangedNotification().block(); + client.closeGracefully().block(); + }).doesNotThrowAnyException(); + } + + @Test + void testInitializeWithSamplingCapability() { + var transport = createMcpTransport(); + + var capabilities = ClientCapabilities.builder().sampling().build(); + + var client = McpClient.using(transport).requestTimeout(TIMEOUT).capabilities(capabilities).sampling(request -> { + return CreateMessageResult.builder().message("test").model("test-model").build(); + }).async(); + + assertThatCode(() -> { + client.initialize().block(Duration.ofSeconds(10)); + client.closeGracefully().block(Duration.ofSeconds(10)); + }).doesNotThrowAnyException(); + } + + @Test + void testInitializeWithAllCapabilities() { + var transport = createMcpTransport(); + + var capabilities = ClientCapabilities.builder() + .experimental(Map.of("feature", "test")) + .roots(true) + .sampling() + .build(); + + Function samplingHandler = request -> { + return CreateMessageResult.builder().message("test").model("test-model").build(); + }; + var client = McpClient.using(transport) + .requestTimeout(TIMEOUT) + .capabilities(capabilities) + .sampling(samplingHandler) + .async(); + + assertThatCode(() -> { + var result = client.initialize().block(Duration.ofSeconds(10)); + assertThat(result).isNotNull(); + assertThat(result.capabilities()).isNotNull(); + client.closeGracefully().block(Duration.ofSeconds(10)); + }).doesNotThrowAnyException(); + } + + // --------------------------------------- + // Logging Tests + // --------------------------------------- + + @Test + void testLoggingLevels() { + // Test all logging levels + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + StepVerifier.create(mcpAsyncClient.setLoggingLevel(level)).verifyComplete(); + } + } + + @Test + void testLoggingConsumer() { + AtomicBoolean logReceived = new AtomicBoolean(false); + var transport = createMcpTransport(); + + var client = McpClient.using(transport) + .requestTimeout(TIMEOUT) + .loggingConsumer(notification -> logReceived.set(true)) + .async(); + + assertThatCode(() -> { + client.initialize().block(Duration.ofSeconds(10)); + client.closeGracefully().block(Duration.ofSeconds(10)); + }).doesNotThrowAnyException(); + } + + @Test + void testLoggingWithNullNotification() { + assertThatThrownBy(() -> mcpAsyncClient.setLoggingLevel(null).block()) + .hasMessageContaining("Logging level must not be null"); + } + +} diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpSyncClientTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpSyncClientTests.java new file mode 100644 index 0000000..59d1896 --- /dev/null +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/client/BlaAbstractMcpSyncClientTests.java @@ -0,0 +1,315 @@ +/* + * 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.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.mcp.spec.ClientMcpTransport; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest; +import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; +import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities; +import org.springframework.ai.mcp.spec.McpSchema.ListResourceTemplatesResult; +import org.springframework.ai.mcp.spec.McpSchema.ListResourcesResult; +import org.springframework.ai.mcp.spec.McpSchema.ListToolsResult; +import org.springframework.ai.mcp.spec.McpSchema.ReadResourceResult; +import org.springframework.ai.mcp.spec.McpSchema.Resource; +import org.springframework.ai.mcp.spec.McpSchema.Root; +import org.springframework.ai.mcp.spec.McpSchema.SubscribeRequest; +import org.springframework.ai.mcp.spec.McpSchema.TextContent; +import org.springframework.ai.mcp.spec.McpSchema.Tool; +import org.springframework.ai.mcp.spec.McpSchema.UnsubscribeRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for MCP Client Session functionality. + * + * @author Christian Tzolov + * @author Dariusz Jędrzejczyk + */ +public abstract class BlaAbstractMcpSyncClientTests { + + private McpSyncClient mcpSyncClient; + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; + + protected ClientMcpTransport mcpTransport; + + abstract protected ClientMcpTransport createMcpTransport(); + + abstract protected void onStart(); + + abstract protected void onClose(); + + @BeforeEach + void setUp() { + onStart(); + this.mcpTransport = createMcpTransport(); + + assertThatCode(() -> { + mcpSyncClient = McpClient.using(mcpTransport) + .requestTimeout(TIMEOUT) + .capabilities(ClientCapabilities.builder().roots(true).build()) + .sync(); + mcpSyncClient.initialize(); + }).doesNotThrowAnyException(); + } + + @AfterEach + void tearDown() { + if (mcpSyncClient != null) { + assertThatCode(() -> mcpSyncClient.close()).doesNotThrowAnyException(); + } + onClose(); + } + + @Test + void testConstructorWithInvalidArguments() { + assertThatThrownBy(() -> McpClient.using(null).sync()).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Transport must not be null"); + + assertThatThrownBy(() -> McpClient.using(mcpTransport).requestTimeout(null).sync()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Request timeout must not be null"); + } + + @Test + void testListTools() { + ListToolsResult tools = mcpSyncClient.listTools(null); + + assertThat(tools).isNotNull().satisfies(result -> { + assertThat(result.tools()).isNotNull().isNotEmpty(); + + Tool firstTool = result.tools().get(0); + assertThat(firstTool.name()).isNotNull(); + assertThat(firstTool.description()).isNotNull(); + }); + } + + @Test + void testCallTools() { + CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); + + assertThat(toolResult).isNotNull().satisfies(result -> { + + assertThat(result.content()).hasSize(1); + + TextContent content = (TextContent) result.content().get(0); + + assertThat(content).isNotNull(); + assertThat(content.text()).isNotNull(); + assertThat(content.text()).contains("7"); + }); + } + + @Test + void testPing() { + assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); + } + + @Test + void testCallTool() { + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); + + CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); + + assertThat(callToolResult).isNotNull().satisfies(result -> { + assertThat(result.content()).isNotNull(); + assertThat(result.isError()).isNull(); + }); + } + + @Test + void testCallToolWithInvalidTool() { + CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); + + assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); + } + + @Test + void testRootsListChanged() { + assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException(); + } + + @Test + void testListResources() { + ListResourcesResult resources = mcpSyncClient.listResources(null); + + assertThat(resources).isNotNull().satisfies(result -> { + assertThat(result.resources()).isNotNull(); + + if (!result.resources().isEmpty()) { + Resource firstResource = result.resources().get(0); + assertThat(firstResource.uri()).isNotNull(); + assertThat(firstResource.name()).isNotNull(); + } + }); + } + + @Test + void testClientSessionState() { + assertThat(mcpSyncClient).isNotNull(); + } + + @Test + void testInitializeWithRootsListProviders() { + var transport = createMcpTransport(); + + var client = McpClient.using(transport) + .requestTimeout(TIMEOUT) + .roots(new Root("file:///test/path", "test-root")) + .sync(); + + assertThatCode(() -> { + client.initialize(); + client.close(); + }).doesNotThrowAnyException(); + } + + @Test + void testAddRoot() { + Root newRoot = new Root("file:///new/test/path", "new-test-root"); + assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException(); + } + + @Test + void testAddRootWithNullValue() { + assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null"); + } + + @Test + void testRemoveRoot() { + Root root = new Root("file:///test/path/to/remove", "root-to-remove"); + assertThatCode(() -> { + mcpSyncClient.addRoot(root); + mcpSyncClient.removeRoot(root.uri()); + }).doesNotThrowAnyException(); + } + + @Test + void testRemoveNonExistentRoot() { + assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri")) + .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); + } + + @Test + void testReadResource() { + ListResourcesResult resources = mcpSyncClient.listResources(null); + + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); + ReadResourceResult result = mcpSyncClient.readResource(firstResource); + + assertThat(result).isNotNull(); + assertThat(result.contents()).isNotNull(); + } + } + + @Test + void testListResourceTemplates() { + ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(null); + + assertThat(result).isNotNull(); + assertThat(result.resourceTemplates()).isNotNull(); + } + + // @Test + void testResourceSubscription() { + ListResourcesResult resources = mcpSyncClient.listResources(null); + + if (!resources.resources().isEmpty()) { + Resource firstResource = resources.resources().get(0); + + // Test subscribe + assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) + .doesNotThrowAnyException(); + + // Test unsubscribe + assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) + .doesNotThrowAnyException(); + } + } + + @Test + void testNotificationHandlers() { + AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false); + AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); + AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); + + var transport = createMcpTransport(); + var client = McpClient.using(transport) + .requestTimeout(TIMEOUT) + .toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) + .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) + .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) + .sync(); + + assertThatCode(() -> { + client.initialize(); + // Trigger notifications + client.sendResourcesListChanged(); + client.promptListChangedNotification(); + client.close(); + }).doesNotThrowAnyException(); + } + + // --------------------------------------- + // Logging Tests + // --------------------------------------- + + @Test + void testLoggingLevels() { + // Test all logging levels + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException(); + } + } + + @Test + void testLoggingConsumer() { + AtomicBoolean logReceived = new AtomicBoolean(false); + var transport = createMcpTransport(); + + var client = McpClient.using(transport) + .requestTimeout(TIMEOUT) + .loggingConsumer(notification -> logReceived.set(true)) + .sync(); + + assertThatCode(() -> { + client.initialize(); + client.close(); + }).doesNotThrowAnyException(); + } + + @Test + void testLoggingWithNullNotification() { + assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null)) + .hasMessageContaining("Logging level must not be null"); + } + +} diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpAsyncServerTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpAsyncServerTests.java new file mode 100644 index 0000000..d765e05 --- /dev/null +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpAsyncServerTests.java @@ -0,0 +1,478 @@ +/* + * 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.server; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import org.springframework.ai.mcp.server.McpServer.PromptRegistration; +import org.springframework.ai.mcp.server.McpServer.ResourceRegistration; +import org.springframework.ai.mcp.server.McpServer.ToolRegistration; +import org.springframework.ai.mcp.spec.McpError; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; +import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult; +import org.springframework.ai.mcp.spec.McpSchema.Prompt; +import org.springframework.ai.mcp.spec.McpSchema.PromptMessage; +import org.springframework.ai.mcp.spec.McpSchema.ReadResourceResult; +import org.springframework.ai.mcp.spec.McpSchema.Resource; +import org.springframework.ai.mcp.spec.McpSchema.ServerCapabilities; +import org.springframework.ai.mcp.spec.McpSchema.Tool; +import org.springframework.ai.mcp.spec.McpTransport; +import org.springframework.ai.mcp.spec.ServerMcpTransport; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Test suite for the {@link McpAsyncServer} that can be used with different + * {@link McpTransport} implementations. + * + * @author Christian Tzolov + */ +public abstract class BlaAbstractMcpAsyncServerTests { + + private static final String TEST_TOOL_NAME = "test-tool"; + + private static final String TEST_RESOURCE_URI = "test://resource"; + + private static final String TEST_PROMPT_NAME = "test-prompt"; + + abstract protected ServerMcpTransport createMcpTransport(); + + protected void onStart() { + } + + protected void onClose() { + } + + @BeforeEach + void setUp() { + } + + @AfterEach + void tearDown() { + onClose(); + } + + // --------------------------------------- + // Server Lifecycle Tests + // --------------------------------------- + + @Test + void testConstructorWithInvalidArguments() { + assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Transport must not be null"); + + assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Server info must not be null"); + } + + @Test + void testGracefulShutdown() { + var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + + StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete(); + } + + @Test + void testImmediateClose() { + var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + + assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException(); + } + + // --------------------------------------- + // Tools Tests + // --------------------------------------- + String emptyJsonSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """; + + @Test + void testAddTool() { + Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema); + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .async(); + + StepVerifier + .create(mcpAsyncServer.addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false)))) + .verifyComplete(); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + } + + @Test + void testAddDuplicateTool() { + Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); + + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tool(duplicateTool, args -> new CallToolResult(List.of(), false)) + .async(); + + StepVerifier + .create(mcpAsyncServer + .addTool(new ToolRegistration(duplicateTool, args -> new CallToolResult(List.of(), false)))) + .verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists"); + }); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + } + + @Test + void testRemoveTool() { + Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); + + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tool(too, args -> new CallToolResult(List.of(), false)) + .async(); + + StepVerifier.create(mcpAsyncServer.removeTool(TEST_TOOL_NAME)).verifyComplete(); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + } + + @Test + void testRemoveNonexistentTool() { + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .async(); + + StepVerifier.create(mcpAsyncServer.removeTool("nonexistent-tool")).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class).hasMessage("Tool with name 'nonexistent-tool' not found"); + }); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + } + + @Test + void testNotifyToolsListChanged() { + Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); + + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tool(too, args -> new CallToolResult(List.of(), false)) + .async(); + + StepVerifier.create(mcpAsyncServer.notifyToolsListChanged()).verifyComplete(); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + } + + // --------------------------------------- + // Resources Tests + // --------------------------------------- + + @Test + void testNotifyResourcesListChanged() { + var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + + StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete(); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + } + + @Test + void testAddResource() { + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().resources(true, false).build()) + .async(); + + Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", + null); + ResourceRegistration registration = new ResourceRegistration(resource, + req -> new ReadResourceResult(List.of())); + + StepVerifier.create(mcpAsyncServer.addResource(registration)).verifyComplete(); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + } + + @Test + void testAddResourceWithNullRegistration() { + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().resources(true, false).build()) + .async(); + + StepVerifier.create(mcpAsyncServer.addResource(null)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource must not be null"); + }); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + } + + @Test + void testAddResourceWithoutCapability() { + // Create a server without resource capabilities + McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .async(); + + Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", + null); + ResourceRegistration registration = new ResourceRegistration(resource, + req -> new ReadResourceResult(List.of())); + + StepVerifier.create(serverWithoutResources.addResource(registration)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); + }); + } + + @Test + void testRemoveResourceWithoutCapability() { + // Create a server without resource capabilities + McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .async(); + + StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); + }); + } + + // --------------------------------------- + // Prompts Tests + // --------------------------------------- + + @Test + void testNotifyPromptsListChanged() { + var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + + StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete(); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))); + } + + @Test + void testAddPromptWithNullRegistration() { + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().prompts(false).build()) + .async(); + + StepVerifier.create(mcpAsyncServer.addPrompt(null)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class).hasMessage("Prompt registration must not be null"); + }); + } + + @Test + void testAddPromptWithoutCapability() { + // Create a server without prompt capabilities + McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .async(); + + Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); + PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( + "Test prompt description", + List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + + StepVerifier.create(serverWithoutPrompts.addPrompt(registration)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with prompt capabilities"); + }); + } + + @Test + void testRemovePromptWithoutCapability() { + // Create a server without prompt capabilities + McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .async(); + + StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with prompt capabilities"); + }); + } + + @Test + void testRemovePrompt() { + String TEST_PROMPT_NAME_TO_REMOVE = "TEST_PROMPT_NAME678"; + + Prompt prompt = new Prompt(TEST_PROMPT_NAME_TO_REMOVE, "Test Prompt", List.of()); + PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( + "Test prompt description", + List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().prompts(true).build()) + .prompts(registration) + .async(); + + StepVerifier.create(mcpAsyncServer.removePrompt(TEST_PROMPT_NAME_TO_REMOVE)).verifyComplete(); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + } + + @Test + void testRemoveNonexistentPrompt() { + var mcpAsyncServer2 = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().prompts(true).build()) + .async(); + + StepVerifier.create(mcpAsyncServer2.removePrompt("nonexistent-prompt")).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Prompt with name 'nonexistent-prompt' not found"); + }); + + assertThatCode(() -> mcpAsyncServer2.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + } + + // --------------------------------------- + // Roots Tests + // --------------------------------------- + + @Test + void testRootsChangeConsumers() { + // Test with single consumer + var rootsReceived = new McpSchema.Root[1]; + var consumerCalled = new boolean[1]; + + var singleConsumerServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + consumerCalled[0] = true; + if (!roots.isEmpty()) { + rootsReceived[0] = roots.get(0); + } + })) + .async(); + + assertThat(singleConsumerServer).isNotNull(); + assertThatCode(() -> singleConsumerServer.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + onClose(); + + // Test with multiple consumers + var consumer1Called = new boolean[1]; + var consumer2Called = new boolean[1]; + var rootsContent = new List[1]; + + var multipleConsumersServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + consumer1Called[0] = true; + rootsContent[0] = roots; + }, roots -> consumer2Called[0] = true)) + .async(); + + assertThat(multipleConsumersServer).isNotNull(); + assertThatCode(() -> multipleConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + onClose(); + + // Test error handling + var errorHandlingServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + throw new RuntimeException("Test error"); + })) + .async(); + + assertThat(errorHandlingServer).isNotNull(); + assertThatCode(() -> errorHandlingServer.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + onClose(); + + // Test without consumers + var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async(); + + assertThat(noConsumersServer).isNotNull(); + assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) + .doesNotThrowAnyException(); + } + + // --------------------------------------- + // Logging Tests + // --------------------------------------- + + @Test + void testLoggingLevels() { + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().logging().build()) + .async(); + + // Test all logging levels + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + var notification = McpSchema.LoggingMessageNotification.builder() + .level(level) + .logger("test-logger") + .data("Test message with level " + level) + .build(); + + StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete(); + } + } + + @Test + void testLoggingWithoutCapability() { + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().build()) // No logging capability + .async(); + + var notification = McpSchema.LoggingMessageNotification.builder() + .level(McpSchema.LoggingLevel.INFO) + .logger("test-logger") + .data("Test log message") + .build(); + + StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete(); + } + + @Test + void testLoggingWithNullNotification() { + var mcpAsyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().logging().build()) + .async(); + + StepVerifier.create(mcpAsyncServer.loggingNotification(null)).verifyError(McpError.class); + } + +} diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpSyncServerTests.java b/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpSyncServerTests.java new file mode 100644 index 0000000..069dc83 --- /dev/null +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/server/BlaAbstractMcpSyncServerTests.java @@ -0,0 +1,446 @@ +/* + * 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.server; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.mcp.server.McpServer.PromptRegistration; +import org.springframework.ai.mcp.server.McpServer.ResourceRegistration; +import org.springframework.ai.mcp.server.McpServer.ToolRegistration; +import org.springframework.ai.mcp.spec.McpError; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; +import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult; +import org.springframework.ai.mcp.spec.McpSchema.Prompt; +import org.springframework.ai.mcp.spec.McpSchema.PromptMessage; +import org.springframework.ai.mcp.spec.McpSchema.ReadResourceResult; +import org.springframework.ai.mcp.spec.McpSchema.Resource; +import org.springframework.ai.mcp.spec.McpSchema.ServerCapabilities; +import org.springframework.ai.mcp.spec.McpSchema.Tool; +import org.springframework.ai.mcp.spec.McpTransport; +import org.springframework.ai.mcp.spec.ServerMcpTransport; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Test suite for the {@link McpSyncServer} that can be used with different + * {@link McpTransport} implementations. + * + * @author Christian Tzolov + */ +public abstract class BlaAbstractMcpSyncServerTests { + + private static final String TEST_TOOL_NAME = "test-tool"; + + private static final String TEST_RESOURCE_URI = "test://resource"; + + private static final String TEST_PROMPT_NAME = "test-prompt"; + + abstract protected ServerMcpTransport createMcpTransport(); + + protected void onStart() { + } + + protected void onClose() { + } + + @BeforeEach + void setUp() { + // onStart(); + } + + @AfterEach + void tearDown() { + onClose(); + } + + // --------------------------------------- + // Server Lifecycle Tests + // --------------------------------------- + + @Test + void testConstructorWithInvalidArguments() { + assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Transport must not be null"); + + assertThatThrownBy(() -> McpServer.using(createMcpTransport()).serverInfo((McpSchema.Implementation) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Server info must not be null"); + } + + @Test + void testGracefulShutdown() { + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testImmediateClose() { + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException(); + } + + @Test + void testGetAsyncServer() { + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThat(mcpSyncServer.getAsyncServer()).isNotNull(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + // --------------------------------------- + // Tools Tests + // --------------------------------------- + + String emptyJsonSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """; + + @Test + void testAddTool() { + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .sync(); + + Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema); + assertThatCode(() -> mcpSyncServer + .addTool(new ToolRegistration(newTool, args -> new CallToolResult(List.of(), false)))) + .doesNotThrowAnyException(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testAddDuplicateTool() { + Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema); + + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tool(duplicateTool, args -> new CallToolResult(List.of(), false)) + .sync(); + + assertThatThrownBy(() -> mcpSyncServer + .addTool(new ToolRegistration(duplicateTool, args -> new CallToolResult(List.of(), false)))) + .isInstanceOf(McpError.class) + .hasMessage("Tool with name '" + TEST_TOOL_NAME + "' already exists"); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testRemoveTool() { + Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", emptyJsonSchema); + + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tool(tool, args -> new CallToolResult(List.of(), false)) + .sync(); + + assertThatCode(() -> mcpSyncServer.removeTool(TEST_TOOL_NAME)).doesNotThrowAnyException(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testRemoveNonexistentTool() { + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().tools(true).build()) + .sync(); + + assertThatThrownBy(() -> mcpSyncServer.removeTool("nonexistent-tool")).isInstanceOf(McpError.class) + .hasMessage("Tool with name 'nonexistent-tool' not found"); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testNotifyToolsListChanged() { + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThatCode(() -> mcpSyncServer.notifyToolsListChanged()).doesNotThrowAnyException(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + // --------------------------------------- + // Resources Tests + // --------------------------------------- + + @Test + void testNotifyResourcesListChanged() { + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThatCode(() -> mcpSyncServer.notifyResourcesListChanged()).doesNotThrowAnyException(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testAddResource() { + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().resources(true, false).build()) + .sync(); + + Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", + null); + ResourceRegistration registration = new ResourceRegistration(resource, + req -> new ReadResourceResult(List.of())); + + assertThatCode(() -> mcpSyncServer.addResource(registration)).doesNotThrowAnyException(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testAddResourceWithNullRegistration() { + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().resources(true, false).build()) + .sync(); + + assertThatThrownBy(() -> mcpSyncServer.addResource(null)).isInstanceOf(McpError.class) + .hasMessage("Resource must not be null"); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testAddResourceWithoutCapability() { + var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description", + null); + ResourceRegistration registration = new ResourceRegistration(resource, + req -> new ReadResourceResult(List.of())); + + assertThatThrownBy(() -> serverWithoutResources.addResource(registration)).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); + } + + @Test + void testRemoveResourceWithoutCapability() { + var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); + } + + // --------------------------------------- + // Prompts Tests + // --------------------------------------- + + @Test + void testNotifyPromptsListChanged() { + var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testAddPromptWithNullRegistration() { + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().prompts(false).build()) + .sync(); + + assertThatThrownBy(() -> mcpSyncServer.addPrompt(null)).isInstanceOf(McpError.class) + .hasMessage("Prompt registration must not be null"); + } + + @Test + void testAddPromptWithoutCapability() { + var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); + PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( + "Test prompt description", + List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + + assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(registration)).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with prompt capabilities"); + } + + @Test + void testRemovePromptWithoutCapability() { + var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with prompt capabilities"); + } + + @Test + void testRemovePrompt() { + Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of()); + PromptRegistration registration = new PromptRegistration(prompt, req -> new GetPromptResult( + "Test prompt description", + List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); + + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().prompts(true).build()) + .prompts(registration) + .sync(); + + assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testRemoveNonexistentPrompt() { + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().prompts(true).build()) + .sync(); + + assertThatThrownBy(() -> mcpSyncServer.removePrompt("nonexistent-prompt")).isInstanceOf(McpError.class) + .hasMessage("Prompt with name 'nonexistent-prompt' not found"); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + // --------------------------------------- + // Roots Tests + // --------------------------------------- + + @Test + void testRootsChangeConsumers() { + // Test with single consumer + var rootsReceived = new McpSchema.Root[1]; + var consumerCalled = new boolean[1]; + + var singleConsumerServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + consumerCalled[0] = true; + if (!roots.isEmpty()) { + rootsReceived[0] = roots.get(0); + } + })) + .sync(); + + assertThat(singleConsumerServer).isNotNull(); + assertThatCode(() -> singleConsumerServer.closeGracefully()).doesNotThrowAnyException(); + onClose(); + + // Test with multiple consumers + var consumer1Called = new boolean[1]; + var consumer2Called = new boolean[1]; + var rootsContent = new List[1]; + + var multipleConsumersServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + consumer1Called[0] = true; + rootsContent[0] = roots; + }, roots -> consumer2Called[0] = true)) + .sync(); + + assertThat(multipleConsumersServer).isNotNull(); + assertThatCode(() -> multipleConsumersServer.closeGracefully()).doesNotThrowAnyException(); + onClose(); + + // Test error handling + var errorHandlingServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .rootsChangeConsumers(List.of(roots -> { + throw new RuntimeException("Test error"); + })) + .sync(); + + assertThat(errorHandlingServer).isNotNull(); + assertThatCode(() -> errorHandlingServer.closeGracefully()).doesNotThrowAnyException(); + onClose(); + + // Test without consumers + var noConsumersServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync(); + + assertThat(noConsumersServer).isNotNull(); + assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException(); + } + + // --------------------------------------- + // Logging Tests + // --------------------------------------- + + @Test + void testLoggingLevels() { + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().logging().build()) + .sync(); + + // Test all logging levels + for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { + var notification = McpSchema.LoggingMessageNotification.builder() + .level(level) + .logger("test-logger") + .data("Test message with level " + level) + .build(); + + assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException(); + } + } + + @Test + void testLoggingWithoutCapability() { + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().build()) // No logging capability + .sync(); + + var notification = McpSchema.LoggingMessageNotification.builder() + .level(McpSchema.LoggingLevel.INFO) + .logger("test-logger") + .data("Test log message") + .build(); + + assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException(); + } + + @Test + void testLoggingWithNullNotification() { + var mcpSyncServer = McpServer.using(createMcpTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().logging().build()) + .sync(); + + assertThatThrownBy(() -> mcpSyncServer.loggingNotification(null)).isInstanceOf(McpError.class); + } + +} diff --git a/mcp-test/src/main/java/org/springframework/ai/mcp/server/transport/BlaBlockingInputStream.java b/mcp-test/src/main/java/org/springframework/ai/mcp/server/transport/BlaBlockingInputStream.java new file mode 100644 index 0000000..270eaae --- /dev/null +++ b/mcp-test/src/main/java/org/springframework/ai/mcp/server/transport/BlaBlockingInputStream.java @@ -0,0 +1,81 @@ +/* +* 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.server.transport; + +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +public class BlaBlockingInputStream extends InputStream { + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + + private volatile boolean completed = false; + + private volatile boolean closed = false; + + @Override + public int read() throws IOException { + if (closed) { + throw new IOException("Stream is closed"); + } + + try { + Integer value = queue.poll(); + if (value == null) { + if (completed) { + return -1; + } + value = queue.take(); // Blocks until data is available + if (value == null && completed) { + return -1; + } + } + return value; + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Read interrupted", e); + } + } + + public void write(int b) { + if (!closed && !completed) { + queue.offer(b); + } + } + + public void write(byte[] data) { + if (!closed && !completed) { + for (byte b : data) { + queue.offer((int) b & 0xFF); + } + } + } + + public void complete() { + this.completed = true; + } + + @Override + public void close() { + this.closed = true; + this.completed = true; + this.queue.clear(); + } + +} \ No newline at end of file diff --git a/mcp-transport/mcp-webflux-sse-transport/README.md b/mcp-transport/mcp-webflux-sse-transport/README.md new file mode 100644 index 0000000..d60d62b --- /dev/null +++ b/mcp-transport/mcp-webflux-sse-transport/README.md @@ -0,0 +1 @@ +# WebFlux SSE Transport diff --git a/mcp-transport/mcp-webflux-sse-transport/pom.xml b/mcp-transport/mcp-webflux-sse-transport/pom.xml new file mode 100644 index 0000000..a4150e2 --- /dev/null +++ b/mcp-transport/mcp-webflux-sse-transport/pom.xml @@ -0,0 +1,114 @@ + + + 4.0.0 + + org.springframework.experimental + mcp-parent + 0.5.0-SNAPSHOT + ../../pom.xml + + mcp-webflux-sse-transport + jar + WebFlux implementation of the Java MCP SSE transport + + https://github.com/spring-projects-experimental/spring-ai-mcp + + + https://github.com/spring-projects-experimental/spring-ai-mcp + git://github.com/spring-projects-experimental/spring-ai-mcp.git + git@github.com:spring-projects-experimental/spring-ai-mcp.git + + + + + org.springframework.experimental + mcp + 0.5.0-SNAPSHOT + + + + org.springframework.experimental + mcp-test + 0.5.0-SNAPSHOT + test + + + + org.springframework + spring-webflux + ${springframework.version} + + + + io.projectreactor.netty + reactor-netty-http + test + + + + + org.springframework + spring-context + ${springframework.version} + test + + + + org.springframework + spring-test + ${springframework.version} + test + + + + org.assertj + assertj-core + ${assert4j.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + io.projectreactor + reactor-test + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + org.awaitility + awaitility + 4.2.0 + test + + + + ch.qos.logback + logback-classic + ${logback.version} + test + + + + + + \ No newline at end of file diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java b/mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransport.java similarity index 96% rename from mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java rename to mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransport.java index 906735f..ed24f6f 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java +++ b/mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransport.java @@ -71,9 +71,9 @@ import org.springframework.web.reactive.function.client.WebClient; * "https://spec.modelcontextprotocol.io/specification/basic/transports/#http-with-sse">MCP * HTTP with SSE Transport Specification */ -public class SseClientTransport implements ClientMcpTransport { +public class WebFluxSseClientTransport implements ClientMcpTransport { - private final static Logger logger = LoggerFactory.getLogger(SseClientTransport.class); + private final static Logger logger = LoggerFactory.getLogger(WebFluxSseClientTransport.class); /** * Event type for JSON-RPC messages received through the SSE connection. The server @@ -137,7 +137,7 @@ public class SseClientTransport implements ClientMcpTransport { * instance * @throws IllegalArgumentException if webClientBuilder is null */ - public SseClientTransport(WebClient.Builder webClientBuilder) { + public WebFluxSseClientTransport(WebClient.Builder webClientBuilder) { this(webClientBuilder, new ObjectMapper()); } @@ -149,7 +149,7 @@ public class SseClientTransport implements ClientMcpTransport { * @param objectMapper the ObjectMapper to use for JSON processing * @throws IllegalArgumentException if either parameter is null */ - public SseClientTransport(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) { + public WebFluxSseClientTransport(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) { Assert.notNull(objectMapper, "ObjectMapper must not be null"); Assert.notNull(webClientBuilder, "WebClient.Builder must not be null"); diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java b/mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebFluxSseServerTransport.java similarity index 97% rename from mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java rename to mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebFluxSseServerTransport.java index 6b27476..0159258 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java +++ b/mcp-transport/mcp-webflux-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebFluxSseServerTransport.java @@ -33,9 +33,9 @@ import org.springframework.web.reactive.function.server.ServerResponse; * * @author Christian Tzolov */ -public class SseServerTransport implements ServerMcpTransport { +public class WebFluxSseServerTransport implements ServerMcpTransport { - private final static Logger logger = LoggerFactory.getLogger(SseServerTransport.class); + private final static Logger logger = LoggerFactory.getLogger(WebFluxSseServerTransport.class); /** * Event type for JSON-RPC messages sent through the SSE connection. @@ -75,7 +75,7 @@ public class SseServerTransport implements ServerMcpTransport { * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization * @param messageEndpoint The endpoint URI where clients should send messages */ - public SseServerTransport(ObjectMapper objectMapper, String messageEndpoint) { + public WebFluxSseServerTransport(ObjectMapper objectMapper, String messageEndpoint) { Assert.notNull(objectMapper, "ObjectMapper must not be null"); Assert.notNull(messageEndpoint, "Message endpoint must not be null"); diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpAsyncClientTests.java b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpAsyncClientTests.java similarity index 84% rename from mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpAsyncClientTests.java rename to mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpAsyncClientTests.java index bce1332..0a15af2 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpAsyncClientTests.java +++ b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpAsyncClientTests.java @@ -20,17 +20,17 @@ import org.junit.jupiter.api.Timeout; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; -import org.springframework.ai.mcp.client.transport.SseClientTransport; +import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport; import org.springframework.ai.mcp.spec.ClientMcpTransport; import org.springframework.web.reactive.function.client.WebClient; /** - * Tests for the {@link McpAsyncClient} with {@link SseClientTransport}. + * Tests for the {@link McpAsyncClient} with {@link WebFluxSseClientTransport}. * * @author Christian Tzolov */ @Timeout(15) // Giving extra time beyond the client timeout -class SseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { +class WebFluxSseMcpAsyncClientTests extends BlaAbstractMcpAsyncClientTests { static String host = "http://localhost:3001"; @@ -43,7 +43,7 @@ class SseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { @Override protected ClientMcpTransport createMcpTransport() { - return new SseClientTransport(WebClient.builder().baseUrl(host)); + return new WebFluxSseClientTransport(WebClient.builder().baseUrl(host)); } @Override diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpSyncClientTests.java b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpSyncClientTests.java similarity index 85% rename from mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpSyncClientTests.java rename to mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpSyncClientTests.java index a0bfacf..2e1a4d9 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpSyncClientTests.java +++ b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/WebFluxSseMcpSyncClientTests.java @@ -20,17 +20,17 @@ import org.junit.jupiter.api.Timeout; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; -import org.springframework.ai.mcp.client.transport.SseClientTransport; +import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport; import org.springframework.ai.mcp.spec.ClientMcpTransport; import org.springframework.web.reactive.function.client.WebClient; /** - * Tests for the {@link McpSyncClient} with {@link SseClientTransport}. + * Tests for the {@link McpSyncClient} with {@link WebFluxSseClientTransport}. * * @author Christian Tzolov */ @Timeout(15) // Giving extra time beyond the client timeout -class SseMcpSyncClientTests extends AbstractMcpSyncClientTests { +class WebFluxSseMcpSyncClientTests extends BlaAbstractMcpSyncClientTests { static String host = "http://localhost:3001"; @@ -43,7 +43,7 @@ class SseMcpSyncClientTests extends AbstractMcpSyncClientTests { @Override protected ClientMcpTransport createMcpTransport() { - return new SseClientTransport(WebClient.builder().baseUrl(host)); + return new WebFluxSseClientTransport(WebClient.builder().baseUrl(host)); } @Override diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/transport/SseClientTransportTests.java b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransportTests.java similarity index 93% rename from mcp/src/test/java/org/springframework/ai/mcp/client/transport/SseClientTransportTests.java rename to mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransportTests.java index 2ab13ff..a75f851 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/client/transport/SseClientTransportTests.java +++ b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/client/transport/WebFluxSseClientTransportTests.java @@ -43,12 +43,12 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Tests for the {@link SseClientTransport} class. + * Tests for the {@link WebFluxSseClientTransport} class. * * @author Christian Tzolov */ @Timeout(15) -class SseClientTransportTests { +class WebFluxSseClientTransportTests { static String host = "http://localhost:3001"; @@ -58,20 +58,20 @@ class SseClientTransportTests { .withExposedPorts(3001) .waitingFor(Wait.forHttp("/").forStatusCode(404)); - private TestSseServerTransport transport; + private TestSseClientTransport transport; private WebClient.Builder webClientBuilder; private ObjectMapper objectMapper; // Test class to access protected methods - static class TestSseServerTransport extends SseClientTransport { + static class TestSseClientTransport extends WebFluxSseClientTransport { private final AtomicInteger inboundMessageCount = new AtomicInteger(0); private Sinks.Many> events = Sinks.many().unicast().onBackpressureBuffer(); - public TestSseServerTransport(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) { + public TestSseClientTransport(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) { super(webClientBuilder, objectMapper); } @@ -118,7 +118,7 @@ class SseClientTransportTests { startContainer(); webClientBuilder = WebClient.builder().baseUrl(host); objectMapper = new ObjectMapper(); - transport = new TestSseServerTransport(webClientBuilder, objectMapper); + transport = new TestSseClientTransport(webClientBuilder, objectMapper); transport.connect(Function.identity()).block(); } @@ -141,10 +141,10 @@ class SseClientTransportTests { @Test void constructorValidation() { - assertThatThrownBy(() -> new SseClientTransport(null)).isInstanceOf(IllegalArgumentException.class) + assertThatThrownBy(() -> new WebFluxSseClientTransport(null)).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("WebClient.Builder must not be null"); - assertThatThrownBy(() -> new SseClientTransport(webClientBuilder, null)) + assertThatThrownBy(() -> new WebFluxSseClientTransport(webClientBuilder, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("ObjectMapper must not be null"); } @@ -252,7 +252,7 @@ class SseClientTransportTests { // Create a WebClient that simulates connection failures WebClient.Builder failingWebClientBuilder = WebClient.builder().baseUrl("http://non-existent-host"); - SseClientTransport failingTransport = new SseClientTransport(failingWebClientBuilder); + WebFluxSseClientTransport failingTransport = new WebFluxSseClientTransport(failingWebClientBuilder); // Verify that the transport attempts to reconnect StepVerifier.create(Mono.delay(Duration.ofSeconds(2))).expectNextCount(1).verifyComplete(); diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseAsyncIntegrationTests.java b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseAsyncIntegrationTests.java new file mode 100644 index 0000000..d9d601f --- /dev/null +++ b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseAsyncIntegrationTests.java @@ -0,0 +1,439 @@ +/* + * 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.server; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; +import reactor.test.StepVerifier; + +import org.springframework.ai.mcp.client.McpClient; +import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport; +import org.springframework.ai.mcp.server.McpServer.ToolRegistration; +import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport; +import org.springframework.ai.mcp.spec.McpError; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; +import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities; +import org.springframework.ai.mcp.spec.McpSchema.CreateMessageRequest; +import org.springframework.ai.mcp.spec.McpSchema.CreateMessageResult; +import org.springframework.ai.mcp.spec.McpSchema.InitializeResult; +import org.springframework.ai.mcp.spec.McpSchema.Role; +import org.springframework.ai.mcp.spec.McpSchema.Root; +import org.springframework.ai.mcp.spec.McpSchema.ServerCapabilities; +import org.springframework.ai.mcp.spec.McpSchema.Tool; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.server.RouterFunctions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +public class WebFluxSseAsyncIntegrationTests { + + private static final int PORT = 8182; + + private static final String MESSAGE_ENDPOINT = "/mcp/message"; + + private DisposableServer httpServer; + + private WebFluxSseServerTransport mcpServerTransport; + + McpClient.Builder clientBuilder; + + @BeforeEach + public void before() { + this.mcpServerTransport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + + HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpServerTransport.getRouterFunction()); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); + this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); + + this.clientBuilder = McpClient + .using(new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:" + PORT))); + } + + @AfterEach + public void after() { + if (httpServer != null) { + httpServer.disposeNow(); + } + } + + // --------------------------------------- + // Sampling Tests + // --------------------------------------- + @Test + void testCreateMessageWithoutInitialization() { + var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + + var messages = List + .of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))); + var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); + + var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null, + McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of()); + + StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Client must be initialized. Call the initialize method first!"); + }); + } + + @Test + void testCreateMessageWithoutSamplingCapabilities() { + + var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + + var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).sync(); + + InitializeResult initResult = client.initialize(); + assertThat(initResult).isNotNull(); + + var messages = List + .of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))); + var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); + + var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null, + McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of()); + + StepVerifier.create(mcpAsyncServer.createMessage(request)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Client must be configured with sampling capabilities"); + }); + } + + @Test + void testCreateMessageSuccess() throws InterruptedException { + + var mcpAsyncServer = McpServer.using(mcpServerTransport).serverInfo("test-server", "1.0.0").async(); + + Function samplingHandler = request -> { + assertThat(request.messages()).hasSize(1); + assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); + + return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", + CreateMessageResult.StopReason.STOP_SEQUENCE); + }; + + var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + .capabilities(ClientCapabilities.builder().sampling().build()) + .sampling(samplingHandler) + .sync(); + + InitializeResult initResult = client.initialize(); + assertThat(initResult).isNotNull(); + + var messages = List + .of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))); + var modelPrefs = new McpSchema.ModelPreferences(List.of(), 1.0, 1.0, 1.0); + + var request = new McpSchema.CreateMessageRequest(messages, modelPrefs, null, + McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, null, 100, List.of(), Map.of()); + + StepVerifier.create(mcpAsyncServer.createMessage(request)).consumeNextWith(result -> { + assertThat(result).isNotNull(); + assertThat(result.role()).isEqualTo(Role.USER); + assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class); + assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message"); + assertThat(result.model()).isEqualTo("MockModelName"); + assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE); + }).verifyComplete(); + } + + // --------------------------------------- + // Roots Tests + // --------------------------------------- + @Test + void testRootsSuccess() { + List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); + + AtomicReference> rootsRef = new AtomicReference<>(); + var mcpServer = McpServer.using(mcpServerTransport) + .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + .sync(); + + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + .roots(roots) + .sync(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(rootsRef.get()).isNull(); + + assertThat(mcpServer.listRoots().roots()).containsAll(roots); + + mcpClient.rootsListChangedNotification(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(roots); + }); + + // Remove a root + mcpClient.removeRoot(roots.get(0).uri()); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(roots.get(1))); + }); + + // Add a new root + var root3 = new Root("uri3://", "root3"); + mcpClient.addRoot(root3); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3)); + }); + + mcpClient.close(); + mcpServer.close(); + } + + @Test + void testRootsWithoutCapability() { + var mcpServer = McpServer.using(mcpServerTransport).rootsChangeConsumer(rootsUpdate -> { + }).sync(); + + // Create client without roots capability + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()) // No + // roots + // capability + .sync(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + // Attempt to list roots should fail + assertThatThrownBy(() -> mcpServer.listRoots().roots()).isInstanceOf(McpError.class) + .hasMessage("Roots not supported"); + + mcpClient.close(); + mcpServer.close(); + } + + @Test + void testRootsWithEmptyRootsList() { + AtomicReference> rootsRef = new AtomicReference<>(); + var mcpServer = McpServer.using(mcpServerTransport) + .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + .sync(); + + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + .roots(List.of()) // Empty roots list + .sync(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + mcpClient.rootsListChangedNotification(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).isEmpty(); + }); + + mcpClient.close(); + mcpServer.close(); + } + + @Test + void testRootsWithMultipleConsumers() { + List roots = List.of(new Root("uri1://", "root1")); + + AtomicReference> rootsRef1 = new AtomicReference<>(); + AtomicReference> rootsRef2 = new AtomicReference<>(); + + var mcpServer = McpServer.using(mcpServerTransport) + .rootsChangeConsumer(rootsUpdate -> rootsRef1.set(rootsUpdate)) + .rootsChangeConsumer(rootsUpdate -> rootsRef2.set(rootsUpdate)) + .sync(); + + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + .roots(roots) + .sync(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + mcpClient.rootsListChangedNotification(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef1.get()).containsAll(roots); + assertThat(rootsRef2.get()).containsAll(roots); + }); + + mcpClient.close(); + mcpServer.close(); + } + + @Test + void testRootsServerCloseWithActiveSubscription() { + List roots = List.of(new Root("uri1://", "root1")); + + AtomicReference> rootsRef = new AtomicReference<>(); + var mcpServer = McpServer.using(mcpServerTransport) + .rootsChangeConsumer(rootsUpdate -> rootsRef.set(rootsUpdate)) + .sync(); + + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + .roots(roots) + .sync(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + mcpClient.rootsListChangedNotification(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(roots); + }); + + // Close server while subscription is active + mcpServer.close(); + + // Verify client can handle server closure gracefully + mcpClient.close(); + } + + // --------------------------------------- + // Tools Tests + // --------------------------------------- + + String emptyJsonSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """; + + @Test + + void testToolCallSuccess() { + + var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); + ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), + request -> { + // perform a blocking call to a remote service + String response = RestClient.create() + .get() + .uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") + .retrieve() + .body(String.class); + assertThat(response).isNotBlank(); + return callResponse; + }); + + var mcpServer = McpServer.using(mcpServerTransport) + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(tool1) + .sync(); + + var mcpClient = clientBuilder.sync(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); + + CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + + assertThat(response).isNotNull(); + assertThat(response).isEqualTo(callResponse); + + mcpClient.close(); + mcpServer.close(); + } + + @Test + void testToolListChangeHandlingSuccess() { + + var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); + ToolRegistration tool1 = new ToolRegistration(new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), + request -> { + // perform a blocking call to a remote service + String response = RestClient.create() + .get() + .uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") + .retrieve() + .body(String.class); + assertThat(response).isNotBlank(); + return callResponse; + }); + + var mcpServer = McpServer.using(mcpServerTransport) + .capabilities(ServerCapabilities.builder().tools(true).build()) + .tools(tool1) + .sync(); + + AtomicReference> rootsRef = new AtomicReference<>(); + var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { + // perform a blocking call to a remote service + String response = RestClient.create() + .get() + .uri("https://github.com/spring-projects-experimental/spring-ai-mcp/blob/main/README.md") + .retrieve() + .body(String.class); + assertThat(response).isNotBlank(); + rootsRef.set(toolsUpdate); + }).sync(); + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(rootsRef.get()).isNull(); + + assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); + + mcpServer.notifyToolsListChanged(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(tool1.tool())); + }); + + // Remove a tool + mcpServer.removeTool("tool1"); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).isEmpty(); + }); + + // Add a new tool + ToolRegistration tool2 = new ToolRegistration(new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), + request -> callResponse); + + mcpServer.addTool(tool2); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(rootsRef.get()).containsAll(List.of(tool2.tool())); + }); + + mcpClient.close(); + mcpServer.close(); + } + +} diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpAsyncServerTests.java similarity index 85% rename from mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java rename to mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpAsyncServerTests.java index 8beda36..753216a 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java +++ b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpAsyncServerTests.java @@ -21,19 +21,19 @@ import org.junit.jupiter.api.Timeout; import reactor.netty.DisposableServer; import reactor.netty.http.server.HttpServer; -import org.springframework.ai.mcp.server.transport.SseServerTransport; +import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport; import org.springframework.ai.mcp.spec.ServerMcpTransport; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import org.springframework.web.reactive.function.server.RouterFunctions; /** - * Tests for {@link McpAsyncServer} using {@link SseServerTransport}. + * Tests for {@link McpAsyncServer} using {@link WebFluxSseServerTransport}. * * @author Christian Tzolov */ @Timeout(15) // Giving extra time beyond the client timeout -class SseMcpAsyncServerTests extends AbstractMcpAsyncServerTests { +class WebFluxSseMcpAsyncServerTests extends BlaAbstractMcpAsyncServerTests { private static final int PORT = 8181; @@ -43,7 +43,7 @@ class SseMcpAsyncServerTests extends AbstractMcpAsyncServerTests { @Override protected ServerMcpTransport createMcpTransport() { - var transport = new SseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + var transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction()); ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpSyncServerTests.java similarity index 83% rename from mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java rename to mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpSyncServerTests.java index af62763..097ed77 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java +++ b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebFluxSseMcpSyncServerTests.java @@ -21,19 +21,19 @@ import org.junit.jupiter.api.Timeout; import reactor.netty.DisposableServer; import reactor.netty.http.server.HttpServer; -import org.springframework.ai.mcp.server.transport.SseServerTransport; +import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport; import org.springframework.ai.mcp.spec.ServerMcpTransport; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import org.springframework.web.reactive.function.server.RouterFunctions; /** - * Tests for {@link McpSyncServer} using {@link SseServerTransport}. + * Tests for {@link McpSyncServer} using {@link WebFluxSseServerTransport}. * * @author Christian Tzolov */ @Timeout(15) // Giving extra time beyond the client timeout -class SseMcpSyncServerTests extends AbstractMcpSyncServerTests { +class WebFluxSseMcpSyncServerTests extends BlaAbstractMcpSyncServerTests { private static final int PORT = 8182; @@ -41,11 +41,11 @@ class SseMcpSyncServerTests extends AbstractMcpSyncServerTests { private DisposableServer httpServer; - private SseServerTransport transport; + private WebFluxSseServerTransport transport; @Override protected ServerMcpTransport createMcpTransport() { - transport = new SseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + transport = new WebFluxSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); return transport; } diff --git a/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/BlockingInputStream.java b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/BlockingInputStream.java new file mode 100644 index 0000000..96690db --- /dev/null +++ b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/BlockingInputStream.java @@ -0,0 +1,81 @@ +/* +* 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.server.transport; + +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +public class BlockingInputStream extends InputStream { + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + + private volatile boolean completed = false; + + private volatile boolean closed = false; + + @Override + public int read() throws IOException { + if (closed) { + throw new IOException("Stream is closed"); + } + + try { + Integer value = queue.poll(); + if (value == null) { + if (completed) { + return -1; + } + value = queue.take(); // Blocks until data is available + if (value == null && completed) { + return -1; + } + } + return value; + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Read interrupted", e); + } + } + + public void write(int b) { + if (!closed && !completed) { + queue.offer(b); + } + } + + public void write(byte[] data) { + if (!closed && !completed) { + for (byte b : data) { + queue.offer((int) b & 0xFF); + } + } + } + + public void complete() { + this.completed = true; + } + + @Override + public void close() { + this.closed = true; + this.completed = true; + this.queue.clear(); + } + +} \ No newline at end of file diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/transport/SseServerTransportTests.java b/mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/_SseServerTransportTests.java_ similarity index 100% rename from mcp/src/test/java/org/springframework/ai/mcp/server/transport/SseServerTransportTests.java rename to mcp-transport/mcp-webflux-sse-transport/src/test/java/org/springframework/ai/mcp/server/transport/_SseServerTransportTests.java_ diff --git a/mcp-transport/mcp-webmvc-sse-transport/README.md b/mcp-transport/mcp-webmvc-sse-transport/README.md new file mode 100644 index 0000000..a7c9941 --- /dev/null +++ b/mcp-transport/mcp-webmvc-sse-transport/README.md @@ -0,0 +1,30 @@ +# WebMVC SSE Server Transport + +```xml + + org.springframework.experimental + mcp-webmvc-sse-transport + 0.5.0-SNAPSHOT + +``` + + + +```java +String MESSAGE_ENDPOINT = "/mcp/message"; + +@Configuration +@EnableWebMvc +static class MyConfig { + + @Bean + public WebMvcSseServerTransport webMvcSseServerTransport() { + return new WebMvcSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + } + + @Bean + public RouterFunction routerFunction(WebMvcSseServerTransport transport) { + return transport.getRouterFunction(); + } +} +``` diff --git a/mcp-transport/mcp-webmvc-sse-transport/pom.xml b/mcp-transport/mcp-webmvc-sse-transport/pom.xml new file mode 100644 index 0000000..d86eb42 --- /dev/null +++ b/mcp-transport/mcp-webmvc-sse-transport/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + + org.springframework.experimental + mcp-parent + 0.5.0-SNAPSHOT + ../../pom.xml + + mcp-webmvc-sse-transport + jar + Spring Web MVC implementation of the Java MCP SSE transport + + https://github.com/spring-projects-experimental/spring-ai-mcp + + + https://github.com/spring-projects-experimental/spring-ai-mcp + git://github.com/spring-projects-experimental/spring-ai-mcp.git + git@github.com:spring-projects-experimental/spring-ai-mcp.git + + + + + org.springframework.experimental + mcp + 0.5.0-SNAPSHOT + + + + org.springframework.experimental + mcp-test + 0.5.0-SNAPSHOT + test + + + + org.springframework + spring-webmvc + ${springframework.version} + + + + + + org.springframework + spring-context + ${springframework.version} + test + + + + org.springframework + spring-test + ${springframework.version} + test + + + + org.assertj + assertj-core + ${assert4j.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + org.awaitility + awaitility + 4.2.0 + test + + + + ch.qos.logback + logback-classic + ${logback.version} + test + + + + io.projectreactor.netty + reactor-netty-http + test + + + io.projectreactor + reactor-test + test + + + jakarta.servlet + jakarta.servlet-api + 5.0.0 + provided + + + + org.apache.tomcat.embed + tomcat-embed-core + 10.1.18 + test + + + + + + \ No newline at end of file diff --git a/mcp-transport/mcp-webmvc-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebMvcSseServerTransport.java b/mcp-transport/mcp-webmvc-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebMvcSseServerTransport.java new file mode 100644 index 0000000..3305da5 --- /dev/null +++ b/mcp-transport/mcp-webmvc-sse-transport/src/main/java/org/springframework/ai/mcp/server/transport/WebMvcSseServerTransport.java @@ -0,0 +1,247 @@ +package org.springframework.ai.mcp.server.transport; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import org.springframework.ai.mcp.spec.McpError; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.ServerMcpTransport; +import org.springframework.ai.mcp.util.Assert; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.RouterFunctions; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * Server-side implementation of the MCP HTTP with SSE transport specification using + * Spring WebMVC. This implementation wraps synchronous WebMVC operations in reactive + * types to maintain compatibility with the reactive transport interface. + * + * @author Christian Tzolov + */ +public class WebMvcSseServerTransport implements ServerMcpTransport { + + private final static Logger logger = LoggerFactory.getLogger(WebMvcSseServerTransport.class); + + /** + * Event type for JSON-RPC messages sent through the SSE connection. + */ + public final static String MESSAGE_EVENT_TYPE = "message"; + + /** + * Event type for sending the message endpoint URI to clients. + */ + public final static String ENDPOINT_EVENT_TYPE = "endpoint"; + + /** + * Default SSE endpoint path as specified by the MCP transport specification. + */ + public final static String SSE_ENDPOINT = "/sse"; + + private final ObjectMapper objectMapper; + + private final String messageEndpoint; + + private final RouterFunction routerFunction; + + /** + * Map of active client sessions, keyed by session ID. + */ + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + /** + * Flag indicating if the transport is shutting down. + */ + private volatile boolean isClosing = false; + + private Function, Mono> connectHandler; + + /** + * Constructs a new FunctionalSseServerTransport. + * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization + * @param messageEndpoint The endpoint URI where clients should send messages + */ + public WebMvcSseServerTransport(ObjectMapper objectMapper, String messageEndpoint) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + Assert.notNull(messageEndpoint, "Message endpoint must not be null"); + + this.objectMapper = objectMapper; + this.messageEndpoint = messageEndpoint; + this.routerFunction = RouterFunctions.route() + .GET(SSE_ENDPOINT, this::handleSseConnection) + .POST(messageEndpoint, this::handleMessage) + .build(); + } + + @Override + public Mono connect(Function, Mono> handler) { + this.connectHandler = handler; + // Server-side transport doesn't initiate connections + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.fromRunnable(() -> { + if (sessions.isEmpty()) { + logger.debug("No active sessions to broadcast message to"); + return; + } + + try { + String jsonText = objectMapper.writeValueAsString(message); + logger.debug("Attempting to broadcast message to {} active sessions", sessions.size()); + + sessions.values().forEach(session -> { + try { + session.messageSink.send(SseEmitter.event().name(MESSAGE_EVENT_TYPE).data(jsonText)); + } + catch (IOException e) { + logger.error("Failed to send message to session {}: {}", session.id, e.getMessage()); + } + }); + } + catch (IOException e) { + logger.error("Failed to serialize message: {}", e.getMessage()); + } + }); + } + + @Override + public T unmarshalFrom(Object data, TypeReference typeRef) { + return this.objectMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(() -> { + isClosing = true; + logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size()); + + sessions.values().forEach(session -> { + String sessionId = session.id; + session.close(); + sessions.remove(sessionId); + }); + + logger.info("Graceful shutdown completed"); + }); + } + + /** + * Get the router function for configuring the web server. + */ + public RouterFunction getRouterFunction() { + return this.routerFunction; + } + + /** + * Handles new SSE connection requests from clients. + */ + private ServerResponse handleSseConnection(ServerRequest request) { + if (isClosing) { + return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); + } + + String sessionId = UUID.randomUUID().toString(); + logger.debug("Creating new SSE connection for session: {}", sessionId); + + ClientSession session = new ClientSession(sessionId); + this.sessions.put(sessionId, session); + + // Send initial endpoint event + try { + session.messageSink.send(SseEmitter.event().name(ENDPOINT_EVENT_TYPE).data(messageEndpoint)); + } + catch (IOException e) { + logger.error("Failed to send initial endpoint event to session {}: {}", sessionId, e.getMessage()); + sessions.remove(sessionId); + return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + + session.messageSink.onCompletion(() -> { + logger.debug("Session {} completed", sessionId); + sessions.remove(sessionId); + }); + + session.messageSink.onError(error -> { + logger.error("Error in session {}: {}", sessionId, error.getMessage()); + sessions.remove(sessionId); + }); + + session.messageSink.onTimeout(() -> { + logger.debug("Session {} timed out", sessionId); + sessions.remove(sessionId); + }); + + return ServerResponse.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(session.messageSink); + } + + /** + * Handles incoming messages from clients. + */ + private ServerResponse handleMessage(ServerRequest request) { + if (isClosing) { + return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); + } + + try { + String body = request.body(String.class); + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body); + + // Convert the message to a Mono, apply the handler, and block for the + // response + McpSchema.JSONRPCMessage response = Mono.just(message).transform(connectHandler).block(); + + return ServerResponse.ok().build(); + } + catch (IllegalArgumentException | IOException e) { + logger.error("Failed to deserialize message: {}", e.getMessage()); + return ServerResponse.badRequest().body(new McpError("Invalid message format")); + } + catch (Exception e) { + logger.error("Error handling message: {}", e.getMessage()); + return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(e.getMessage())); + } + } + + /** + * Represents an active client session. + */ + private static class ClientSession { + + private final String id; + + private final SseEmitter messageSink; + + ClientSession(String id) { + this.id = id; + this.messageSink = new SseEmitter(0L); // No timeout + logger.debug("Session {} initialized with SSE emitter", id); + } + + void close() { + logger.debug("Closing session: {}", id); + try { + messageSink.complete(); + logger.debug("Successfully completed SSE emitter for session {}", id); + } + catch (Exception e) { + logger.warn("Failed to complete SSE emitter for session {}: {}", id, e.getMessage()); + } + } + + } + +} diff --git a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseAsyncServerTransportTests.java b/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseAsyncServerTransportTests.java new file mode 100644 index 0000000..18dcd16 --- /dev/null +++ b/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseAsyncServerTransportTests.java @@ -0,0 +1,129 @@ +/* + * 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.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.Timeout; + +import org.springframework.ai.mcp.server.transport.WebMvcSseServerTransport; +import org.springframework.ai.mcp.spec.ServerMcpTransport; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +@Timeout(15) +class WebMvcSseAsyncServerTransportTests extends BlaAbstractMcpAsyncServerTests { + + private static final String MESSAGE_ENDPOINT = "/mcp/message"; + + private static final int PORT = 8181; + + private Tomcat tomcat; + + private WebMvcSseServerTransport transport; + + @Configuration + @EnableWebMvc + static class TestConfig { + + @Bean + public WebMvcSseServerTransport webMvcSseServerTransport() { + return new WebMvcSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + } + + @Bean + public RouterFunction routerFunction(WebMvcSseServerTransport transport) { + return transport.getRouterFunction(); + } + + } + + private AnnotationConfigWebApplicationContext appContext; + + @Override + protected ServerMcpTransport createMcpTransport() { + // Set up Tomcat first + tomcat = new Tomcat(); + tomcat.setPort(PORT); + + // Set Tomcat base directory to java.io.tmpdir to avoid permission issues + String baseDir = System.getProperty("java.io.tmpdir"); + tomcat.setBaseDir(baseDir); + + // Use the same directory for document base + Context context = tomcat.addContext("", baseDir); + + // Create and configure Spring WebMvc context + appContext = new AnnotationConfigWebApplicationContext(); + appContext.register(TestConfig.class); + appContext.setServletContext(context.getServletContext()); + appContext.refresh(); + + // Get the transport from Spring context + transport = appContext.getBean(WebMvcSseServerTransport.class); + + // Create DispatcherServlet with our Spring context + DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext); + // dispatcherServlet.setThrowExceptionIfNoHandlerFound(true); + + // Add servlet to Tomcat and get the wrapper + var wrapper = Tomcat.addServlet(context, "dispatcherServlet", dispatcherServlet); + wrapper.setLoadOnStartup(1); + context.addServletMappingDecoded("/*", "dispatcherServlet"); + + try { + tomcat.start(); + tomcat.getConnector(); // Create and start the connector + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + + return transport; + } + + @Override + protected void onStart() { + } + + @Override + protected void onClose() { + if (transport != null) { + transport.closeGracefully().block(); + } + if (appContext != null) { + appContext.close(); + } + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + +} diff --git a/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseSyncServerTransportTests.java b/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseSyncServerTransportTests.java new file mode 100644 index 0000000..fb3926d --- /dev/null +++ b/mcp-transport/mcp-webmvc-sse-transport/src/test/java/org/springframework/ai/mcp/server/WebMvcSseSyncServerTransportTests.java @@ -0,0 +1,129 @@ +/* + * 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.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.Timeout; + +import org.springframework.ai.mcp.server.transport.WebMvcSseServerTransport; +import org.springframework.ai.mcp.spec.ServerMcpTransport; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +@Timeout(15) +class WebMvcSseSyncServerTransportTests extends BlaAbstractMcpSyncServerTests { + + private static final String MESSAGE_ENDPOINT = "/mcp/message"; + + private static final int PORT = 8181; + + private Tomcat tomcat; + + private WebMvcSseServerTransport transport; + + @Configuration + @EnableWebMvc + static class TestConfig { + + @Bean + public WebMvcSseServerTransport webMvcSseServerTransport() { + return new WebMvcSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); + } + + @Bean + public RouterFunction routerFunction(WebMvcSseServerTransport transport) { + return transport.getRouterFunction(); + } + + } + + private AnnotationConfigWebApplicationContext appContext; + + @Override + protected ServerMcpTransport createMcpTransport() { + // Set up Tomcat first + tomcat = new Tomcat(); + tomcat.setPort(PORT); + + // Set Tomcat base directory to java.io.tmpdir to avoid permission issues + String baseDir = System.getProperty("java.io.tmpdir"); + tomcat.setBaseDir(baseDir); + + // Use the same directory for document base + Context context = tomcat.addContext("", baseDir); + + // Create and configure Spring WebMvc context + appContext = new AnnotationConfigWebApplicationContext(); + appContext.register(TestConfig.class); + appContext.setServletContext(context.getServletContext()); + appContext.refresh(); + + // Get the transport from Spring context + transport = appContext.getBean(WebMvcSseServerTransport.class); + + // Create DispatcherServlet with our Spring context + DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext); + // dispatcherServlet.setThrowExceptionIfNoHandlerFound(true); + + // Add servlet to Tomcat and get the wrapper + var wrapper = Tomcat.addServlet(context, "dispatcherServlet", dispatcherServlet); + wrapper.setLoadOnStartup(1); + context.addServletMappingDecoded("/*", "dispatcherServlet"); + + try { + tomcat.start(); + tomcat.getConnector(); // Create and start the connector + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + + return transport; + } + + @Override + protected void onStart() { + } + + @Override + protected void onClose() { + if (transport != null) { + transport.closeGracefully().block(); + } + if (appContext != null) { + appContext.close(); + } + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + +} diff --git a/mcp/pom.xml b/mcp/pom.xml index 30bb8ce..86d4ff4 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -21,6 +21,7 @@ + org.slf4j slf4j-api @@ -38,11 +39,11 @@ reactor-core - org.springframework - spring-webflux + spring-webmvc ${springframework.version} + test @@ -116,4 +117,4 @@ - + \ No newline at end of file diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/FlowSseClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/FlowSseClient.java new file mode 100644 index 0000000..c337e5d --- /dev/null +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/FlowSseClient.java @@ -0,0 +1,202 @@ +/* +* 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.transport; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * @author Christian Tzolov + */ +public class FlowSseClient { + + private final HttpClient httpClient; + + private static final Pattern EVENT_DATA_PATTERN = Pattern.compile("^data:(.+)$", Pattern.MULTILINE); + + private static final Pattern EVENT_ID_PATTERN = Pattern.compile("^id:(.+)$", Pattern.MULTILINE); + + private static final Pattern EVENT_TYPE_PATTERN = Pattern.compile("^event:(.+)$", Pattern.MULTILINE); + + public FlowSseClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + public void subscribe(String url, SSEEventHandler eventHandler) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "text/event-stream") + .header("Cache-Control", "no-cache") + .GET() + .build(); + + StringBuilder eventBuilder = new StringBuilder(); + AtomicReference currentEventId = new AtomicReference<>(); + AtomicReference currentEventType = new AtomicReference<>("message"); + + Flow.Subscriber lineSubscriber = new Flow.Subscriber<>() { + private Flow.Subscription subscription; + + @Override + public void onSubscribe(Flow.Subscription subscription) { + this.subscription = subscription; + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(String line) { + if (line.isEmpty()) { + // Empty line means end of event + if (eventBuilder.length() > 0) { + String eventData = eventBuilder.toString(); + SSEEvent event = new SSEEvent(currentEventId.get(), currentEventType.get(), eventData.trim()); + eventHandler.onEvent(event); + eventBuilder.setLength(0); + } + } + else { + if (line.startsWith("data:")) { + var matcher = EVENT_DATA_PATTERN.matcher(line); + if (matcher.find()) { + eventBuilder.append(matcher.group(1).trim()).append("\n"); + } + } + else if (line.startsWith("id:")) { + var matcher = EVENT_ID_PATTERN.matcher(line); + if (matcher.find()) { + currentEventId.set(matcher.group(1).trim()); + } + } + else if (line.startsWith("event:")) { + var matcher = EVENT_TYPE_PATTERN.matcher(line); + if (matcher.find()) { + currentEventType.set(matcher.group(1).trim()); + } + } + } + subscription.request(1); + } + + @Override + public void onError(Throwable throwable) { + eventHandler.onError(throwable); + } + + @Override + public void onComplete() { + // Handle any remaining event data + if (eventBuilder.length() > 0) { + String eventData = eventBuilder.toString(); + SSEEvent event = new SSEEvent(currentEventId.get(), currentEventType.get(), eventData.trim()); + eventHandler.onEvent(event); + } + } + }; + + Function, HttpResponse.BodySubscriber> subscriberFactory = subscriber -> HttpResponse.BodySubscribers + .fromLineSubscriber(subscriber); + + CompletableFuture> future = this.httpClient.sendAsync(request, + info -> subscriberFactory.apply(lineSubscriber)); + + future.thenAccept(response -> { + if (response.statusCode() != 200) { + throw new RuntimeException("Failed to connect to SSE stream: " + response.statusCode()); + } + }).exceptionally(throwable -> { + eventHandler.onError(throwable); + return null; + }); + } + + // Event handler interface + public interface SSEEventHandler { + + void onEvent(SSEEvent event); + + void onError(Throwable error); + + } + + // SSE Event class + public static class SSEEvent { + + private final String id; + + private final String type; + + private final String data; + + public SSEEvent(String id, String type, String data) { + this.id = id; + this.type = type; + this.data = data; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + + public String getData() { + return data; + } + + @Override + public String toString() { + return "SSEEvent{" + "id='" + id + '\'' + ", type='" + type + '\'' + ", data='" + data + '\'' + '}'; + } + + } + + public static void main(String[] args) { + + FlowSseClient sseClient = new FlowSseClient( + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()); + + var eventHandler = new SSEEventHandler() { + @Override + public void onEvent(SSEEvent event) { + System.out.println("Received event: " + event); + } + + @Override + public void onError(Throwable error) { + System.out.println("Error: " + error); + } + }; + sseClient.subscribe("http://localhost:8080/sse", eventHandler); + + try { + Thread.sleep(100000); + } + catch (InterruptedException e) { + e.printStackTrace(); + } + } + +} diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/HttpClientSseClientTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/HttpClientSseClientTransport.java new file mode 100644 index 0000000..6423682 --- /dev/null +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/HttpClientSseClientTransport.java @@ -0,0 +1,192 @@ +/* + * 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.transport; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.mcp.client.transport.FlowSseClient.SSEEvent; +import org.springframework.ai.mcp.spec.ClientMcpTransport; +import org.springframework.ai.mcp.spec.McpError; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.JSONRPCMessage; +import org.springframework.ai.mcp.util.Assert; + +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +/** + * Server-Sent Events (SSE) implementation of the + * {@link org.springframework.ai.mcp.spec.McpTransport} that follows the MCP HTTP with SSE + * transport specification, using Java's HttpClient. + * + * @author Christian Tzolov + */ +public class HttpClientSseClientTransport implements ClientMcpTransport { + + private final static Logger logger = LoggerFactory.getLogger(HttpClientSseClientTransport.class); + + private final static String MESSAGE_EVENT_TYPE = "message"; + + private final static String ENDPOINT_EVENT_TYPE = "endpoint"; + + private final static String SSE_ENDPOINT = "/sse"; + + private final String baseUri; + + private final FlowSseClient sseClient; + + private final HttpClient httpClient; + + protected ObjectMapper objectMapper; + + private volatile boolean isClosing = false; + + private CountDownLatch closeLatch = new CountDownLatch(1); + + private final AtomicReference messageEndpoint = new AtomicReference<>(); + + private final AtomicReference> connectionFuture = new AtomicReference<>(); + + public HttpClientSseClientTransport(String baseUri) { + this(HttpClient.newBuilder(), baseUri, new ObjectMapper()); + } + + public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, String baseUri, ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + Assert.notNull(clientBuilder, "clientBuilder must not be null"); + this.baseUri = baseUri; + this.objectMapper = objectMapper; + this.httpClient = clientBuilder.connectTimeout(Duration.ofSeconds(10)).build(); + this.sseClient = new FlowSseClient(this.httpClient); + } + + @Override + public Mono connect(Function, Mono> handler) { + CompletableFuture future = new CompletableFuture<>(); + connectionFuture.set(future); + + sseClient.subscribe(this.baseUri + SSE_ENDPOINT, new FlowSseClient.SSEEventHandler() { + @Override + public void onEvent(SSEEvent event) { + if (isClosing) { + return; + } + + try { + if (ENDPOINT_EVENT_TYPE.equals(event.getType())) { + String endpoint = event.getData(); + messageEndpoint.set(endpoint); + closeLatch.countDown(); + future.complete(null); + } + else if (MESSAGE_EVENT_TYPE.equals(event.getType())) { + JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, event.getData()); + handler.apply(Mono.just(message)).subscribe(); + } + else { + logger.error("Received unrecognized SSE event type: " + event.getType()); + } + } + catch (IOException e) { + logger.error("Error processing SSE event", e); + future.completeExceptionally(e); + } + } + + @Override + public void onError(Throwable error) { + if (!isClosing) { + logger.error("SSE connection error", error); + future.completeExceptionally(error); + } + } + }); + + return Mono.fromFuture(future); + } + + @Override + public Mono sendMessage(JSONRPCMessage message) { + if (isClosing) { + return Mono.empty(); + } + + try { + closeLatch.await(10, TimeUnit.SECONDS); + } + catch (InterruptedException e) { + return Mono.error(new McpError("Failed to wait for the message endpoint")); + } + + String endpoint = messageEndpoint.get(); + if (endpoint == null) { + return Mono.error(new McpError("No message endpoint available")); + } + + try { + String jsonText = this.objectMapper.writeValueAsString(message); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(this.baseUri + endpoint)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonText)) + .build(); + + return Mono.fromFuture( + httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).thenAccept(response -> { + if (response.statusCode() != 200) { + logger.error("Error sending message: {}", response.statusCode()); + } + })); + } + catch (IOException e) { + if (!isClosing) { + return Mono.error(new RuntimeException("Failed to serialize message", e)); + } + return Mono.empty(); + } + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(() -> { + isClosing = true; + CompletableFuture future = connectionFuture.get(); + if (future != null && !future.isDone()) { + future.cancel(true); + } + }); + } + + @Override + public T unmarshalFrom(Object data, TypeReference typeRef) { + return this.objectMapper.convertValue(data, typeRef); + } + +} diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SSEClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SSEClient.java new file mode 100644 index 0000000..ec8c8eb --- /dev/null +++ b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SSEClient.java @@ -0,0 +1,183 @@ +/* +* 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.transport; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +public class SSEClient { + + private final HttpClient httpClient; + + private static final Pattern EVENT_DATA_PATTERN = Pattern.compile("^data:(.+)$", Pattern.MULTILINE); + + private static final Pattern EVENT_ID_PATTERN = Pattern.compile("^id:(.+)$", Pattern.MULTILINE); + + private static final Pattern EVENT_TYPE_PATTERN = Pattern.compile("^event:(.+)$", Pattern.MULTILINE); + + public SSEClient() { + this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + } + + public void subscribe(String url, SSEEventHandler eventHandler) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "text/event-stream") + .header("Cache-Control", "no-cache") + .GET() + .build(); + + CompletableFuture future = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofLines()) + .thenAccept(response -> { + if (response.statusCode() == 200) { + processSSEStream(response, eventHandler); + } + else { + throw new RuntimeException("Failed to connect to SSE stream: " + response.statusCode()); + } + }); + + // Handle errors + future.exceptionally(throwable -> { + eventHandler.onError(throwable); + return null; + }); + } + + private void processSSEStream(HttpResponse> response, SSEEventHandler eventHandler) { + StringBuilder eventBuilder = new StringBuilder(); + AtomicReference currentEventId = new AtomicReference<>(); + AtomicReference currentEventType = new AtomicReference<>("message"); // default + // event + // type + + response.body().forEach(line -> { + if (line.isEmpty()) { + // Empty line means end of event + if (eventBuilder.length() > 0) { + String eventData = eventBuilder.toString(); + SSEEvent event = parseEvent(eventData, currentEventId.get(), currentEventType.get()); + eventHandler.onEvent(event); + eventBuilder.setLength(0); + } + } + else { + if (line.startsWith("data:")) { + var matcher = EVENT_DATA_PATTERN.matcher(line); + if (matcher.find()) { + eventBuilder.append(matcher.group(1).trim()).append("\n"); + } + } + else if (line.startsWith("id:")) { + var matcher = EVENT_ID_PATTERN.matcher(line); + if (matcher.find()) { + currentEventId.set(matcher.group(1).trim()); + } + } + else if (line.startsWith("event:")) { + var matcher = EVENT_TYPE_PATTERN.matcher(line); + if (matcher.find()) { + currentEventType.set(matcher.group(1).trim()); + } + } + } + }); + } + + private SSEEvent parseEvent(String eventData, String eventId, String eventType) { + return new SSEEvent(eventId, eventType, eventData.trim()); + } + + // Event handler interface + public interface SSEEventHandler { + + void onEvent(SSEEvent event); + + void onError(Throwable error); + + } + + // SSE Event class + public static class SSEEvent { + + private final String id; + + private final String type; + + private final String data; + + public SSEEvent(String id, String type, String data) { + this.id = id; + this.type = type; + this.data = data; + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + + public String getData() { + return data; + } + + @Override + public String toString() { + return "SSEEvent{" + "id='" + id + '\'' + ", type='" + type + '\'' + ", data='" + data + '\'' + '}'; + } + + } + + public static void main(String[] args) { + SSEClient sseClient = new SSEClient(); + + var eventHandler = new SSEEventHandler() { + @Override + public void onEvent(SSEEvent event) { + System.out.println("Received event: " + event); + } + + @Override + public void onError(Throwable error) { + System.out.println("Error: " + error); + } + }; + sseClient.subscribe("http://localhost:8080/sse", eventHandler); + + try { + Thread.sleep(100000); + } + catch (InterruptedException e) { + e.printStackTrace(); + } + } + +} \ No newline at end of file diff --git a/mcp/src/main/java/org/springframework/ai/mcp/util/Utils.java b/mcp/src/main/java/org/springframework/ai/mcp/util/Utils.java index 47e38c7..622ad26 100644 --- a/mcp/src/main/java/org/springframework/ai/mcp/util/Utils.java +++ b/mcp/src/main/java/org/springframework/ai/mcp/util/Utils.java @@ -21,11 +21,9 @@ import java.util.Map; import reactor.util.annotation.Nullable; -import org.springframework.lang.Contract; - /** * Miscellaneous utility methods. - * + * * @author Christian Tzolov */ @@ -52,7 +50,6 @@ public final class Utils { * @param collection the Collection to check * @return whether the given Collection is empty */ - @Contract("null -> true") public static boolean isEmpty(@Nullable Collection collection) { return (collection == null || collection.isEmpty()); } @@ -63,7 +60,6 @@ public final class Utils { * @param map the Map to check * @return whether the given Map is empty */ - @Contract("null -> true") public static boolean isEmpty(@Nullable Map map) { return (map == null || map.isEmpty()); } diff --git a/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java b/mcp/src/test/java/org/springframework/ai/mcp/attic/_DemoServer.java_ similarity index 100% rename from mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java rename to mcp/src/test/java/org/springframework/ai/mcp/attic/_DemoServer.java_ diff --git a/mcp/src/test/java/org/springframework/ai/mcp/attic/_HttpServletSseServerTransport.java_ b/mcp/src/test/java/org/springframework/ai/mcp/attic/_HttpServletSseServerTransport.java_ new file mode 100644 index 0000000..3bededd --- /dev/null +++ b/mcp/src/test/java/org/springframework/ai/mcp/attic/_HttpServletSseServerTransport.java_ @@ -0,0 +1,297 @@ +/* +* 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.server.transport; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import org.springframework.ai.mcp.spec.McpError; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.ServerMcpTransport; + +/** + * A Servlet-based implementation of the MCP HTTP with SSE transport specification. This + * implementation provides similar functionality to FunctionalSseServerTransport but uses + * the traditional Servlet API instead of WebFlux. + * + * @author Christian Tzolov + * @since 1.0.0 + */ + +@WebServlet(asyncSupported = true) +public class HttpServletSseServerTransport extends HttpServlet implements ServerMcpTransport { + + private static final Logger logger = LoggerFactory.getLogger(HttpServletSseServerTransport.class); + + public static final String SSE_ENDPOINT = "/sse"; + + public static final String MESSAGE_EVENT_TYPE = "message"; + + public static final String ENDPOINT_EVENT_TYPE = "endpoint"; + + private final ObjectMapper objectMapper; + + private final String messageEndpoint; + + private final Map sessions = new ConcurrentHashMap<>(); + + private final AtomicBoolean isClosing = new AtomicBoolean(false); + + private Function, Mono> connectHandler; + + public HttpServletSseServerTransport(ObjectMapper objectMapper, String messageEndpoint) { + this.objectMapper = objectMapper; + this.messageEndpoint = messageEndpoint; + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + String pathInfo = request.getPathInfo(); + if (!SSE_ENDPOINT.equals(pathInfo)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + if (isClosing.get()) { + response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down"); + return; + } + + response.setContentType("text/event-stream"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Connection", "keep-alive"); + response.setHeader("Access-Control-Allow-Origin", "*"); + + String sessionId = UUID.randomUUID().toString(); + AsyncContext asyncContext = request.startAsync(); + asyncContext.setTimeout(0); + + PrintWriter writer = response.getWriter(); + ClientSession session = new ClientSession(sessionId, asyncContext, writer); + sessions.put(sessionId, session); + + // Send initial endpoint event + sendEvent(writer, ENDPOINT_EVENT_TYPE, messageEndpoint); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + if (isClosing.get()) { + response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down"); + return; + } + + String pathInfo = request.getPathInfo(); + if (!messageEndpoint.equals(pathInfo)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + try { + BufferedReader reader = request.getReader(); + StringBuilder body = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + body.append(line); + } + + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body.toString()); + + if (connectHandler != null) { + connectHandler.apply(Mono.just(message)).subscribe(responseMessage -> { + try { + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + String jsonResponse = objectMapper.writeValueAsString(responseMessage); + PrintWriter writer = response.getWriter(); + writer.write(jsonResponse); + writer.flush(); + } + catch (Exception e) { + logger.error("Error sending response: {}", e.getMessage()); + try { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Error processing response: " + e.getMessage()); + } + catch (IOException ex) { + logger.error("Failed to send error response: {}", ex.getMessage()); + } + } + }, error -> { + try { + logger.error("Error processing message: {}", error.getMessage()); + McpError mcpError = new McpError(error.getMessage()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + String jsonError = objectMapper.writeValueAsString(mcpError); + PrintWriter writer = response.getWriter(); + writer.write(jsonError); + writer.flush(); + } + catch (IOException e) { + logger.error("Failed to send error response: {}", e.getMessage()); + try { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Error sending error response: " + e.getMessage()); + } + catch (IOException ex) { + logger.error("Failed to send error response: {}", ex.getMessage()); + } + } + }); + } + else { + response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "No message handler configured"); + } + } + catch (Exception e) { + logger.error("Invalid message format: {}", e.getMessage()); + try { + McpError mcpError = new McpError("Invalid message format: " + e.getMessage()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + String jsonError = objectMapper.writeValueAsString(mcpError); + PrintWriter writer = response.getWriter(); + writer.write(jsonError); + writer.flush(); + } + catch (IOException ex) { + logger.error("Failed to send error response: {}", ex.getMessage()); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid message format"); + } + } + } + + @Override + public Mono connect(Function, Mono> handler) { + this.connectHandler = handler; + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (sessions.isEmpty()) { + logger.debug("No active sessions to broadcast message to"); + return Mono.empty(); + } + + return Mono.create(sink -> { + try { + String jsonText = objectMapper.writeValueAsString(message); + + sessions.values().forEach(session -> { + try { + sendEvent(session.writer, MESSAGE_EVENT_TYPE, jsonText); + } + catch (IOException e) { + logger.error("Failed to send message to session {}: {}", session.id, e.getMessage()); + removeSession(session); + } + }); + + sink.success(); + } + catch (Exception e) { + logger.error("Failed to process message: {}", e.getMessage()); + sink.error(new McpError("Failed to process message: " + e.getMessage())); + } + }); + } + + @Override + public void close() { + ServerMcpTransport.super.close(); + } + + @Override + public T unmarshalFrom(Object data, TypeReference typeRef) { + return objectMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + isClosing.set(true); + logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size()); + + return Mono.create(sink -> { + sessions.values().forEach(this::removeSession); + sink.success(); + }); + } + + private void sendEvent(PrintWriter writer, String eventType, String data) throws IOException { + writer.write("event: " + eventType + "\n"); + writer.write("data: " + data + "\n\n"); + writer.flush(); + + if (writer.checkError()) { + throw new IOException("Client disconnected"); + } + } + + private void removeSession(ClientSession session) { + sessions.remove(session.id); + session.asyncContext.complete(); + } + + private static class ClientSession { + + private final String id; + + private final AsyncContext asyncContext; + + private final PrintWriter writer; + + ClientSession(String id, AsyncContext asyncContext, PrintWriter writer) { + this.id = id; + this.asyncContext = asyncContext; + this.writer = writer; + } + + } + + @Override + public void destroy() { + closeGracefully().block(); + super.destroy(); + } + +} diff --git a/mcp/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java b/mcp/src/test/java/org/springframework/ai/mcp/attic/_MainSSE.java_ similarity index 100% rename from mcp/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java rename to mcp/src/test/java/org/springframework/ai/mcp/attic/_MainSSE.java_ diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/transport/HttpClientSseClientTransportTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/transport/HttpClientSseClientTransportTests.java new file mode 100644 index 0000000..f50aeb3 --- /dev/null +++ b/mcp/src/test/java/org/springframework/ai/mcp/client/transport/HttpClientSseClientTransportTests.java @@ -0,0 +1,290 @@ +/* + * 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.transport; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest; +import org.springframework.http.codec.ServerSentEvent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Tests for the {@link HttpClientSseClientTransport} class. + * + * @author Christian Tzolov + */ +@Timeout(15) +class HttpClientSseClientTransportTests { + + static String host = "http://localhost:3001"; + + @SuppressWarnings("resource") + GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v1") + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withExposedPorts(3001) + .waitingFor(Wait.forHttp("/").forStatusCode(404)); + + private TestHttpClientSseClientTransport transport; + + // Test class to access protected methods + static class TestHttpClientSseClientTransport extends HttpClientSseClientTransport { + + private final AtomicInteger inboundMessageCount = new AtomicInteger(0); + + private Sinks.Many> events = Sinks.many().unicast().onBackpressureBuffer(); + + public TestHttpClientSseClientTransport(String baseUri) { + super(baseUri); + } + + public int getInboundMessageCount() { + return inboundMessageCount.get(); + } + + public void simulateEndpointEvent(String jsonMessage) { + events.tryEmitNext(ServerSentEvent.builder().event("endpoint").data(jsonMessage).build()); + inboundMessageCount.incrementAndGet(); + } + + public void simulateMessageEvent(String jsonMessage) { + events.tryEmitNext(ServerSentEvent.builder().event("message").data(jsonMessage).build()); + inboundMessageCount.incrementAndGet(); + } + + } + + void startContainer() { + container.start(); + int port = container.getMappedPort(3001); + host = "http://" + container.getHost() + ":" + port; + } + + @BeforeEach + void setUp() { + startContainer(); + transport = new TestHttpClientSseClientTransport(host); + transport.connect(Function.identity()).block(); + } + + @AfterEach + void afterEach() { + if (transport != null) { + assertThatCode(() -> transport.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + } + cleanup(); + } + + void cleanup() { + container.stop(); + } + + @Test + void testMessageProcessing() { + // Create a test message + JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", + Map.of("key", "value")); + + // Simulate receiving the message + transport.simulateMessageEvent(""" + { + "jsonrpc": "2.0", + "method": "test-method", + "id": "test-id", + "params": {"key": "value"} + } + """); + + // Subscribe to messages and verify + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + + assertThat(transport.getInboundMessageCount()).isEqualTo(1); + } + + @Test + void testResponseMessageProcessing() { + // Simulate receiving a response message + transport.simulateMessageEvent(""" + { + "jsonrpc": "2.0", + "id": "test-id", + "result": {"status": "success"} + } + """); + + // Create and send a request message + JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", + Map.of("key", "value")); + + // Verify message handling + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + + assertThat(transport.getInboundMessageCount()).isEqualTo(1); + } + + @Test + void testErrorMessageProcessing() { + // Simulate receiving an error message + transport.simulateMessageEvent(""" + { + "jsonrpc": "2.0", + "id": "test-id", + "error": { + "code": -32600, + "message": "Invalid Request" + } + } + """); + + // Create and send a request message + JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", + Map.of("key", "value")); + + // Verify message handling + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + + assertThat(transport.getInboundMessageCount()).isEqualTo(1); + } + + @Test + void testNotificationMessageProcessing() { + // Simulate receiving a notification message (no id) + transport.simulateMessageEvent(""" + { + "jsonrpc": "2.0", + "method": "update", + "params": {"status": "processing"} + } + """); + + // Verify the notification was processed + assertThat(transport.getInboundMessageCount()).isEqualTo(1); + } + + @Test + void testGracefulShutdown() { + // Test graceful shutdown + StepVerifier.create(transport.closeGracefully()).verifyComplete(); + + // Create a test message + JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", + Map.of("key", "value")); + + // Verify message is not processed after shutdown + StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); + + // Message count should remain 0 after shutdown + assertThat(transport.getInboundMessageCount()).isEqualTo(0); + } + + @Test + void testRetryBehavior() { + // Create a client that simulates connection failures + HttpClientSseClientTransport failingTransport = new HttpClientSseClientTransport("http://non-existent-host"); + + // Verify that the transport attempts to reconnect + StepVerifier.create(Mono.delay(Duration.ofSeconds(2))).expectNextCount(1).verifyComplete(); + + // Clean up + failingTransport.closeGracefully().block(); + } + + @Test + void testMultipleMessageProcessing() { + // Simulate receiving multiple messages in sequence + transport.simulateMessageEvent(""" + { + "jsonrpc": "2.0", + "method": "method1", + "id": "id1", + "params": {"key": "value1"} + } + """); + + transport.simulateMessageEvent(""" + { + "jsonrpc": "2.0", + "method": "method2", + "id": "id2", + "params": {"key": "value2"} + } + """); + + // Create and send corresponding messages + JSONRPCRequest message1 = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method1", "id1", + Map.of("key", "value1")); + + JSONRPCRequest message2 = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method2", "id2", + Map.of("key", "value2")); + + // Verify both messages are processed + StepVerifier.create(transport.sendMessage(message1).then(transport.sendMessage(message2))).verifyComplete(); + + // Verify message count + assertThat(transport.getInboundMessageCount()).isEqualTo(2); + } + + @Test + void testMessageOrderPreservation() { + // Simulate receiving messages in a specific order + transport.simulateMessageEvent(""" + { + "jsonrpc": "2.0", + "method": "first", + "id": "1", + "params": {"sequence": 1} + } + """); + + transport.simulateMessageEvent(""" + { + "jsonrpc": "2.0", + "method": "second", + "id": "2", + "params": {"sequence": 2} + } + """); + + transport.simulateMessageEvent(""" + { + "jsonrpc": "2.0", + "method": "third", + "id": "3", + "params": {"sequence": 3} + } + """); + + // Verify message count and order + assertThat(transport.getInboundMessageCount()).isEqualTo(3); + } + +} diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java index e51130f..7da6775 100644 --- a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java +++ b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java @@ -18,7 +18,6 @@ package org.springframework.ai.mcp.server; import java.time.Duration; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/_SseAsyncIntegrationTests.java_ similarity index 100% rename from mcp/src/test/java/org/springframework/ai/mcp/server/SseAsyncIntegrationTests.java rename to mcp/src/test/java/org/springframework/ai/mcp/server/_SseAsyncIntegrationTests.java_ diff --git a/pom.xml b/pom.xml index 6384d2b..29aee45 100644 --- a/pom.xml +++ b/pom.xml @@ -66,7 +66,7 @@ 17 17 - 1.0.0-M4 + 1.0.0-M5 3.26.3 5.10.2 5.11.0 @@ -100,9 +100,12 @@ mcp + mcp-transport/mcp-webflux-sse-transport + mcp-transport/mcp-webmvc-sse-transport spring-ai-mcp - spring-ai-mcp-sample mcp-docs + mcp-test + samples/spring-ai-mcp-sample diff --git a/spring-ai-mcp-sample/README.md b/samples/spring-ai-mcp-sample/README.md similarity index 100% rename from spring-ai-mcp-sample/README.md rename to samples/spring-ai-mcp-sample/README.md diff --git a/spring-ai-mcp-sample/pom.xml b/samples/spring-ai-mcp-sample/pom.xml similarity index 71% rename from spring-ai-mcp-sample/pom.xml rename to samples/spring-ai-mcp-sample/pom.xml index edc4671..578f803 100644 --- a/spring-ai-mcp-sample/pom.xml +++ b/samples/spring-ai-mcp-sample/pom.xml @@ -18,6 +18,11 @@ + + org.springframework.experimental + mcp-webflux-sse-transport + ${project.version} + org.springframework.experimental spring-ai-mcp @@ -29,6 +34,28 @@ spring-boot-starter-webflux ${spring-boot.version} + + + + org.springframework.boot diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java similarity index 82% rename from spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java rename to samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java index 50b47e3..d159755 100644 --- a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java +++ b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java @@ -15,7 +15,7 @@ */ package org.springframework.ai.mcp.sample.client; -import org.springframework.ai.mcp.client.transport.SseClientTransport; +import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport; import org.springframework.web.reactive.function.client.WebClient; /** @@ -25,7 +25,7 @@ import org.springframework.web.reactive.function.client.WebClient; public class ClientSse { public static void main(String[] args) { - var transport = new SseClientTransport(WebClient.builder().baseUrl("http://localhost:8080")); + var transport = new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:8080")); new SampleClient(transport).run(); } diff --git a/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse2.java b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse2.java new file mode 100644 index 0000000..174721f --- /dev/null +++ b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse2.java @@ -0,0 +1,32 @@ +/* +* 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.sample.client; + +import org.springframework.ai.mcp.client.transport.HttpClientSseClientTransport; + +/** + * @author Christian Tzolov + */ + +public class ClientSse2 { + + public static void main(String[] args) { + var transport = new HttpClientSseClientTransport("http://localhost:8080"); + + new SampleClient(transport).run(); + } + +} diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java similarity index 100% rename from spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java rename to samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java similarity index 100% rename from spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java rename to samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java similarity index 100% rename from spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java rename to samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java similarity index 85% rename from spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java rename to samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java index 08deb08..de7a95c 100644 --- a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java +++ b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java @@ -13,8 +13,8 @@ import org.springframework.ai.mcp.server.McpServer; import org.springframework.ai.mcp.server.McpServer.PromptRegistration; import org.springframework.ai.mcp.server.McpServer.ResourceRegistration; import org.springframework.ai.mcp.server.McpServer.ToolRegistration; -import org.springframework.ai.mcp.server.transport.SseServerTransport; import org.springframework.ai.mcp.server.transport.StdioServerTransport; +import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport; import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult; @@ -22,7 +22,6 @@ import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification; import org.springframework.ai.mcp.spec.McpSchema.PromptMessage; import org.springframework.ai.mcp.spec.McpSchema.Role; import org.springframework.ai.mcp.spec.McpSchema.TextContent; -import org.springframework.ai.mcp.spec.McpSchema.Tool; import org.springframework.ai.mcp.spec.ServerMcpTransport; import org.springframework.ai.mcp.spring.ToolHelper; import org.springframework.ai.model.function.FunctionCallback; @@ -47,18 +46,55 @@ public class McpServerConfig { // SSE transport @Bean @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse") - public SseServerTransport sseServerTransport() { - return new SseServerTransport(new ObjectMapper(), "/mcp/message"); + public WebFluxSseServerTransport sseServerTransport() { + return new WebFluxSseServerTransport(new ObjectMapper(), "/mcp/message"); } // Router function for SSE transport used by Spring WebFlux to start an HTTP // server. @Bean @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse") - public RouterFunction mcpRouterFunction(SseServerTransport transport) { + public RouterFunction mcpRouterFunction(WebFluxSseServerTransport transport) { return transport.getRouterFunction(); } + // SSE transport + // @Bean + // @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse2") + // public HttpServletSseServerTransport sseServerTransport2() { + // var httpTransport = new HttpServletSseServerTransport(new ObjectMapper(), + // "/mcp/message"); + + // // Create and configure Jetty server + // Server server = new Server(8080); + + // ServletContextHandler context = new + // ServletContextHandler(ServletContextHandler.SESSIONS); + // context.setContextPath("/"); + // server.setHandler(context); + + // // Add our SSE servlet + // context.addServlet(new ServletHolder(httpTransport), "/sse"); + + // // Start the server + // try { + // server.start(); + // System.out.println("Server started on http://localhost:8080/sse"); + // server.join(); + // } + // catch (Exception e) { + // e.printStackTrace(); + // try { + // server.stop(); + // } + // catch (Exception e1) { + // e1.printStackTrace(); + // } + // } + + // return httpTransport; + // } + @Bean public McpAsyncServer mcpServer(ServerMcpTransport transport, OpenLibrary openLibrary) { // @formatter:off @@ -79,20 +115,20 @@ public class McpServerConfig { .tools(calculatorToolRegistration(), ToolHelper.toToolRegistration( FunctionCallback.builder() - .description("Get transaction payment status") .method("paymentTransactionStatus",String.class, String.class) + .description("Get transaction payment status") .targetClass(McpServerConfig.class) .build()), ToolHelper.toToolRegistration( FunctionCallback.builder() - .description("To upper case") - .function("toUpperCase", new Function() { - @Override - public String apply(String s) { - return s.toUpperCase(); - } - }) - .inputType(String.class) + .function("toUpperCase", new Function() { + @Override + public String apply(String s) { + return s.toUpperCase(); + } + }) + .description("To upper case") + .inputType(String.class) .build())) .tools(openLibraryToolRegistrations(openLibrary)) .async(); @@ -104,14 +140,14 @@ public class McpServerConfig { public static List openLibraryToolRegistrations(OpenLibrary openLibrary) { var books = FunctionCallback.builder() - .description("Get list of Books by title") .method("getBooks", String.class) + .description("Get list of Books by title") .targetObject(openLibrary) .build(); var bookTitlesByAuthor = FunctionCallback.builder() - .description("Get book titles by author") .method("getBookTitlesByAuthor", String.class) + .description("Get book titles by author") .targetObject(openLibrary) .build(); diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/OpenLibrary.java b/samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/OpenLibrary.java similarity index 100% rename from spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/OpenLibrary.java rename to samples/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/OpenLibrary.java diff --git a/spring-ai-mcp-sample/src/main/resources/application.properties b/samples/spring-ai-mcp-sample/src/main/resources/application.properties similarity index 87% rename from spring-ai-mcp-sample/src/main/resources/application.properties rename to samples/spring-ai-mcp-sample/src/main/resources/application.properties index 4a0226c..2530c58 100644 --- a/spring-ai-mcp-sample/src/main/resources/application.properties +++ b/samples/spring-ai-mcp-sample/src/main/resources/application.properties @@ -3,6 +3,6 @@ # NOTE: You must disable the banner and the console logging # to allow the STDIO transport to work !!! spring.main.banner-mode=off -logging.pattern.console= +# logging.pattern.console= transport.mode=sse \ No newline at end of file diff --git a/spring-ai-mcp/pom.xml b/spring-ai-mcp/pom.xml index 8b60b10..67c9404 100644 --- a/spring-ai-mcp/pom.xml +++ b/spring-ai-mcp/pom.xml @@ -24,12 +24,29 @@ org.springframework.ai spring-ai-core ${spring-ai.version} + + + org.springframework + spring-context + + + org.springframework + spring-messaging + + org.springframework.experimental mcp ${project.version} + + + org.springframework + spring-core + ${springframework.version} + +