diff --git a/README.md b/README.md index 2e27d66..597e2c7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Spring AI MCP is an experimental project that provides Java and Spring Framework The project consists of two main modules: -### spring-ai-mcp-core +### [spring-ai-mcp-core](./spring-ai-mcp-core/README.md) The core module provides a Java implementation of the Model Context Protocol specification. It includes: - Synchronous and asynchronous client implementations @@ -20,7 +20,9 @@ The core module provides a Java implementation of the Model Context Protocol spe - Stdio-based server transport - Reactive programming support using Project Reactor -### spring-ai-mcp-spring +[find more](./spring-ai-mcp-core/README.md) + +### [spring-ai-mcp-spring](./spring-ai-mcp-spring/README.md) The Spring integration module provides Spring-specific functionality: - Integration with Spring AI's function calling system diff --git a/pom.xml b/pom.xml index 7de059e..0da76c9 100644 --- a/pom.xml +++ b/pom.xml @@ -70,7 +70,12 @@ 3.26.3 5.11.3 5.11.0 + 1.20.4 + 2.0.16 + 1.5.12 + 2.18.2 + 6.1.13 3.11.0 @@ -347,4 +352,4 @@ - \ No newline at end of file + diff --git a/spring-ai-mcp-architecture.jpg b/spring-ai-mcp-architecture.jpg index 7447e47..7463b84 100644 Binary files a/spring-ai-mcp-architecture.jpg and b/spring-ai-mcp-architecture.jpg differ diff --git a/spring-ai-mcp-core/README.md b/spring-ai-mcp-core/README.md index cc3a72e..57fecec 100644 --- a/spring-ai-mcp-core/README.md +++ b/spring-ai-mcp-core/README.md @@ -10,13 +10,19 @@ This SDK implements the Model Context Protocol, enabling seamless integration wi - Synchronous and Asynchronous client implementations - Standard MCP operations support: + - Protocol version compatibility negotiation + - Client-server capability exchange - Tool discovery and execution - - Resource management and templates - - Prompt handling and management + - Resource management with URI templates - Resource subscription system + - Prompt handling and management - Server initialization and ping -- Stdio-based server transport +- Multiple transport implementations: + - Stdio-based transport for process-based communication + - SSE-based transport for HTTP streaming - Reactive programming support using Project Reactor +- Configurable request timeouts +- Customizable JSON serialization/deserialization ## Installation @@ -38,6 +44,41 @@ Detailed UML class diagrams showing the relationships between components can be ## Usage +### Transport Layer Options + +The SDK provides two transport implementations: + +#### StdioServerTransport +Standard I/O based transport for process-based communication with MCP servers: + +```java +ServerParameters params = ServerParameters.builder("npx") + .args("-y", "@modelcontextprotocol/server-everything", "dir") + .build(); +McpTransport transport = new StdioServerTransport(params); +``` + +#### SseServerTransport +Server-Sent Events (SSE) based transport following the MCP HTTP with SSE transport specification: + +```java +WebClient.Builder webClientBuilder = WebClient.builder() + .baseUrl("http://your-mcp-server"); +McpTransport transport = new SseServerTransport(webClientBuilder); + +// Or with custom ObjectMapper +ObjectMapper mapper = new ObjectMapper(); +McpTransport transport = new SseServerTransport(webClientBuilder, mapper); +``` + +The SSE transport provides: +- Bidirectional communication over HTTP +- Automatic reconnection for transient failures +- Inbound message streaming via SSE +- Outbound message delivery via HTTP POST +- Graceful shutdown handling +- Configurable JSON serialization + ### Sync Client Example ```java @@ -47,32 +88,32 @@ ServerParameters params = ServerParameters.builder("npx") .build(); try (McpSyncClient client = McpClient.sync(new StdioServerTransport(params))) { - // Initialize connection + // Initialize connection with protocol version and capabilities McpSchema.InitializeResult initResult = client.initialize(); - // List tools synchronously - McpSchema.ListToolsResult tools = client.listTools(null); + // List available tools + McpSchema.ListToolsResult tools = client.listTools(); - // Call tool synchronously + // Execute a tool McpSchema.CallToolResult result = client.callTool( new McpSchema.CallToolRequest("echo", Map.of("message", "Hello!")) ); // Resource management - McpSchema.ListResourcesResult resources = client.listResources(null); + McpSchema.ListResourcesResult resources = client.listResources(); McpSchema.ReadResourceResult resource = client.readResource( new McpSchema.ReadResourceRequest("resource-uri") ); - // Prompt management - ListPromptsResult prompts = client.listPrompts(null); + // List and retrieve prompts + ListPromptsResult prompts = client.listPrompts(); GetPromptResult prompt = client.getPrompt( new McpSchema.GetPromptRequest("prompt-id", Map.of()) ); } ``` -### Async Client Example +### Async Client Example with Custom Configuration ```java // Create server parameters @@ -80,43 +121,42 @@ ServerParameters params = ServerParameters.builder("npx") .args("-y", "@modelcontextprotocol/server-everything", "dir") .build(); -// Initialize the async client +// Initialize async client with custom timeout and object mapper McpAsyncClient client = McpClient.async( - new StdioServerTransport(params) + new StdioServerTransport(params), + Duration.ofSeconds(30), + new ObjectMapper() ); -// Initialize the connection -var promptResult = client.initialize() - flatMap(result -> { - // Connection initialized - return client.listTools(null); +// Initialize connection and chain operations +var result = client.initialize() + .flatMap(initResult -> { + // Connection initialized with protocol version compatibility + return client.listTools(); }) .flatMap(tools -> { - // Process tools + // Process available tools return client.callTool(new McpSchema.CallToolRequest("echo", Map.of("message", "Hello MCP!"))); }) - .flatMap(result -> { - // Handle tool result - return client.listPrompts(null); + .flatMap(toolResult -> { + // Handle tool execution result + return client.listPrompts(); }) .flatMap(prompts -> { // Process available prompts return client.getPrompt(new McpSchema.GetPromptRequest("prompt-id", Map.of())); }); -// Handle prompt result, e.g. by blocking on it -McpSchema.GetPromptResult result = promptResult.block(); - -// Resource management example -var resourcesResult = client.listResources(null) +// Subscribe to resource changes +var subscription = client.listResources() .flatMap(resources -> { - // Subscribe to resource changes return client.subscribeResource(new McpSchema.SubscribeRequest("resource-uri")); }); -// Handle resources result -resourcesResult.block(); +// Handle results reactively or block if needed +McpSchema.GetPromptResult promptResult = result.block(); +subscription.block(); // Cleanup client.closeGracefully().block(); @@ -124,62 +164,68 @@ client.closeGracefully().block(); ## Architecture -The SDK follows a layered architecture: +The SDK follows a layered architecture with clear separation of concerns: ### Core Components -- **McpClient**: Factory class for creating sync and async clients -- **McpAsyncClient**: Primary async implementation using Project Reactor -- **McpSyncClient**: Synchronous wrapper around the async client +- **McpClient**: Factory class for creating sync and async clients with optional custom configuration +- **McpAsyncClient**: Primary async implementation using Project Reactor for non-blocking operations +- **McpSyncClient**: Synchronous wrapper around the async client for blocking operations - **McpSession**: Core session interface defining communication patterns -- **McpTransport**: Transport layer interface +- **McpTransport**: Transport layer interface for server communication - **McpSchema**: Comprehensive protocol schema definitions -- **AbstractMcpTransport**: Base transport implementation -- **StdioServerTransport**: Stdio-based server communication +- **DefaultMcpSession**: Base implementation of the session management +- **StdioServerTransport**: Standard I/O based server communication +- **SseServerTransport**: HTTP-based transport using Server-Sent Events for bidirectional communication ### Key Interactions -1. Client initialization - - Transport setup - - Server connection establishment - - Protocol handshake +1. Client Initialization + - Transport setup and connection establishment + - Protocol version compatibility check + - Capability negotiation + - Implementation details exchange 2. Message Flow - - JSON-RPC message creation + - JSON-RPC message creation and validation - Transport layer handling - - Response processing - - Error handling + - Response processing with type safety + - Error handling with specific error codes 3. Resource Management - - Resource listing and reading - - Template management - - Subscription handling - - Change notifications + - Resource discovery and listing + - URI template-based resource access + - Subscription system for change notifications + - Resource content retrieval 4. Prompt System - - Prompt discovery - - Prompt retrieval - - Change notifications + - Prompt discovery and listing + - Parameter-based prompt retrieval + - Change notifications support + - Prompt content management 5. Tool Execution - - Tool discovery - - Parameter validation - - Execution handling - - Result processing + - Tool discovery and capability checking + - Parameter validation and processing + - Execution handling with timeout support + - Result processing with error handling ## Error Handling The SDK provides comprehensive error handling through the McpError class: -- Transport-level errors -- Protocol violations +- Protocol version incompatibility +- Transport-level communication errors +- JSON-RPC protocol violations - Tool execution failures -- Resource access errors -- Subscription handling errors +- Resource access and subscription errors - Prompt management errors -- Timeout handling +- Request timeout handling +- Server capability mismatches +- SSE connection failures and retry handling +- HTTP request/response errors ## Contributing diff --git a/spring-ai-mcp-core/docs/CLIENT.SSE.TRANSPORT.md b/spring-ai-mcp-core/docs/CLIENT.SSE.TRANSPORT.md new file mode 100644 index 0000000..62bce86 --- /dev/null +++ b/spring-ai-mcp-core/docs/CLIENT.SSE.TRANSPORT.md @@ -0,0 +1,144 @@ +# SSE Server Transport Implementation for MCP + +## Overview + +The `SseServerTransport` 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 +SseServerTransport transport = new SseServerTransport(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 `SseServerTransportTests.java`. diff --git a/spring-ai-mcp-core/docs/CLIENT.STDIO.TRANSPORT.md b/spring-ai-mcp-core/docs/CLIENT.STDIO.TRANSPORT.md new file mode 100644 index 0000000..b78ab55 --- /dev/null +++ b/spring-ai-mcp-core/docs/CLIENT.STDIO.TRANSPORT.md @@ -0,0 +1,123 @@ +# StdioServerTransport + +The `StdioServerTransport` 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 +StdioServerTransport transport = new StdioServerTransport(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/spring-ai-mcp-core/docs/class-diagrams.puml b/spring-ai-mcp-core/docs/class-diagrams.puml index f98cd2b..14a7f3a 100644 --- a/spring-ai-mcp-core/docs/class-diagrams.puml +++ b/spring-ai-mcp-core/docs/class-diagrams.puml @@ -57,6 +57,10 @@ class StdioServerTransport { -ServerParameters parameters } +class SseServerTransport { + -WebClient webClient +} + class McpSchema { +class ErrorCodes +interface Request @@ -72,6 +76,7 @@ class McpError { McpTransport <|.. AbstractMcpTransport AbstractMcpTransport <|-- StdioServerTransport +AbstractMcpTransport <|-- SseServerTransport McpSession <|-- DefaultMcpSession DefaultMcpSession <|-- McpAsyncClient McpClient ..> McpAsyncClient : creates diff --git a/spring-ai-mcp-core/docs/spring-ai-mcp-uml-classdiagram.svg b/spring-ai-mcp-core/docs/spring-ai-mcp-uml-classdiagram.svg index a46137e..fc6b72b 100644 --- a/spring-ai-mcp-core/docs/spring-ai-mcp-uml-classdiagram.svg +++ b/spring-ai-mcp-core/docs/spring-ai-mcp-uml-classdiagram.svg @@ -1,3 +1 @@ -McpTransportvoid start()void close()Mono<Void> closeGracefully()void setInboudMessageHandler()void setInboundErrorHandler()Mono<Void> sendMessage()AbstractMcpTransportObjectMapper objectMappergetInboundSink()getOutboundSink()getErrorSink()McpSession<T> Mono<T> sendRequest()Mono<Void> sendNotification()Mono<Void> closeGracefully()DefaultMcpSessionMcpTransport transportObjectMapper objectMapperMcpClientasync()sync()McpAsyncClientMono<InitializeResult> initialize()Mono<CallToolResult> callTool()Mono<ListToolsResult> listTools()Mono<ListResourcesResult> listResources()Mono<ReadResourceResult> readResource()Mono<ListPromptsResult> listPrompts()Mono<GetPromptResult> getPrompt()McpSyncClientInitializeResult initialize()CallToolResult callTool()ListToolsResult listTools()ListResourcesResult listResources()ReadResourceResult readResource()ListPromptsResult listPrompts()GetPromptResult getPrompt()StdioServerTransportProcess serverProcessServerParameters parametersMcpSchemaclass ErrorCodesinterface Requestinterface JSONRPCMessageinterface ResourceContentsinterface ContentMcpErrorint codeString messagecreatescreatesusesthrows \ No newline at end of file +McpTransportvoid start()void close()Mono<Void> closeGracefully()void setInboudMessageHandler()void setInboundErrorHandler()Mono<Void> sendMessage()AbstractMcpTransportObjectMapper objectMappergetInboundSink()getOutboundSink()getErrorSink()McpSession<T> Mono<T> sendRequest()Mono<Void> sendNotification()Mono<Void> closeGracefully()DefaultMcpSessionMcpTransport transportObjectMapper objectMapperMcpClientasync()sync()McpAsyncClientMono<InitializeResult> initialize()Mono<CallToolResult> callTool()Mono<ListToolsResult> listTools()Mono<ListResourcesResult> listResources()Mono<ReadResourceResult> readResource()Mono<ListPromptsResult> listPrompts()Mono<GetPromptResult> getPrompt()McpSyncClientInitializeResult initialize()CallToolResult callTool()ListToolsResult listTools()ListResourcesResult listResources()ReadResourceResult readResource()ListPromptsResult listPrompts()GetPromptResult getPrompt()StdioServerTransportProcess serverProcessServerParameters parametersSseServerTransportWebClient webClientMcpSchemaclass ErrorCodesinterface Requestinterface JSONRPCMessageinterface ResourceContentsinterface ContentMcpErrorint codeString messagecreatescreatesusesthrows \ No newline at end of file diff --git a/spring-ai-mcp-core/pom.xml b/spring-ai-mcp-core/pom.xml index e3ba148..87438b9 100644 --- a/spring-ai-mcp-core/pom.xml +++ b/spring-ai-mcp-core/pom.xml @@ -21,16 +21,35 @@ + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + + ch.qos.logback + logback-classic + ${logback.version} + + com.fasterxml.jackson.core jackson-databind - 2.18.2 + ${jackson.version} + io.projectreactor reactor-core + + org.springframework + spring-webflux + ${springframework.version} + + org.assertj assertj-core @@ -54,8 +73,14 @@ reactor-test test + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + - \ No newline at end of file + diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java index 7c5af87..819409c 100644 --- a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java +++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java @@ -36,7 +36,7 @@ import org.springframework.ai.mcp.spec.McpTransport; */ public class McpAsyncClient extends DefaultMcpSession { - private static TypeReference VOID_TYPE_REFERENCE = new TypeReference() { + private static TypeReference VOID_TYPE_REFERENCE = new TypeReference<>() { }; public McpAsyncClient(McpTransport transport) { @@ -113,10 +113,10 @@ public class McpAsyncClient extends DefaultMcpSession { // -------------------------- // Tools // -------------------------- - private static TypeReference CALL_TOOL_RESULT_TYPE_REF = new TypeReference() { + private static TypeReference CALL_TOOL_RESULT_TYPE_REF = new TypeReference<>() { }; - private static TypeReference LIST_TOOLS_RESULT_TYPE_REF = new TypeReference() { + private static TypeReference LIST_TOOLS_RESULT_TYPE_REF = new TypeReference<>() { }; /** @@ -149,13 +149,13 @@ public class McpAsyncClient extends DefaultMcpSession { // Resources // -------------------------- - private static TypeReference LIST_RESOURCES_RESULT_TYPE_REF = new TypeReference() { + private static TypeReference LIST_RESOURCES_RESULT_TYPE_REF = new TypeReference<>() { }; - private static TypeReference READ_RESOURCE_RESULT_TYPE_REF = new TypeReference() { + private static TypeReference READ_RESOURCE_RESULT_TYPE_REF = new TypeReference<>() { }; - private static TypeReference LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF = new TypeReference() { + private static TypeReference LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF = new TypeReference<>() { }; /** @@ -251,10 +251,10 @@ public class McpAsyncClient extends DefaultMcpSession { // -------------------------- // Prompts // -------------------------- - private static TypeReference LIST_PROMPTS_RESULT_TYPE_REF = new TypeReference() { + private static TypeReference LIST_PROMPTS_RESULT_TYPE_REF = new TypeReference<>() { }; - private static TypeReference GET_PROMPT_RESULT_TYPE_REF = new TypeReference() { + private static TypeReference GET_PROMPT_RESULT_TYPE_REF = new TypeReference<>() { }; /** diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/sse/SseServerTransport.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/sse/SseServerTransport.java new file mode 100644 index 0000000..504f504 --- /dev/null +++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/sse/SseServerTransport.java @@ -0,0 +1,353 @@ +/* + * 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.sse; + +import java.io.IOException; +import java.util.concurrent.Executors; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.SynchronousSink; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.util.retry.Retry; +import reactor.util.retry.Retry.RetrySignal; + +import org.springframework.ai.mcp.client.util.Assert; +import org.springframework.ai.mcp.spec.AbstractMcpTransport; +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.JSONRPCMessage; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Server-Sent Events (SSE) implementation of the + * {@link org.springframework.ai.mcp.spec.McpTransport} that follows the MCP HTTP with SSE + * transport specification. + * + *

+ * This transport establishes a bidirectional communication channel where: + *

    + *
  • Inbound messages are received through an SSE connection from the server
  • + *
  • Outbound messages are sent via HTTP POST requests to a server-provided + * endpoint
  • + *
+ * + *

+ * The message flow follows these steps: + *

    + *
  1. The client establishes an SSE connection to the server's /sse endpoint
  2. + *
  3. The server sends an 'endpoint' event containing the URI for sending messages
  4. + * + *

    + * This implementation handles automatic reconnection for transient failures and provides + * graceful shutdown capabilities. It uses {@link WebClient} for HTTP communications and + * supports JSON serialization/deserialization of messages. + * + * @author Christian Tzolov + * @see MCP + * HTTP with SSE Transport Specification + */ +public class SseServerTransport extends AbstractMcpTransport { + + private final static Logger logger = LoggerFactory.getLogger(SseServerTransport.class); + + /** + * Event type for JSON-RPC messages received through the SSE connection. The server + * sends messages with this event type to transmit JSON-RPC protocol data. + */ + private final static String MESSAGE_EVENT_TYPE = "message"; + + /** + * Event type for receiving the message endpoint URI from the server. The server MUST + * send this event when a client connects, providing the URI where the client should + * send its messages via HTTP POST. + */ + private final static String ENDPOINT_EVENT_TYPE = "endpoint"; + + /** + * Default SSE endpoint path as specified by the MCP transport specification. This + * endpoint is used to establish the SSE connection with the server. + */ + private final static String SSE_ENDPOINT = "/sse"; + + /** + * Type reference for parsing SSE events containing string data. + */ + private final static ParameterizedTypeReference> SSE_TYPE = new ParameterizedTypeReference<>() { + }; + + /** + * WebClient instance for handling both SSE connections and HTTP POST requests. Used + * for establishing the SSE connection and sending outbound messages. + */ + private final WebClient webClient; + + /** + * ObjectMapper for serializing outbound messages and deserializing inbound messages. + * Handles conversion between JSON-RPC messages and their string representation. + */ + private ObjectMapper objectMapper; + + /** + * Dedicated scheduler for processing outbound messages. Ensures sequential processing + * of outbound messages on a single thread. + */ + private Scheduler outboundScheduler; + + /** + * Subscription for the SSE connection handling inbound messages. Used for cleanup + * during transport shutdown. + */ + private Disposable inboundSubscription; + + /** + * Subscription for the outbound message processing pipeline. Used for cleanup during + * transport shutdown. + */ + private Disposable outboundSubscription; + + /** + * Flag indicating if the transport is in the process of shutting down. Used to + * prevent new operations during shutdown and handle cleanup gracefully. + */ + private volatile boolean isClosing = false; + + /** + * Sink for managing the message endpoint URI provided by the server. Stores the most + * recent endpoint URI and makes it available for outbound message processing. + */ + protected final Sinks.Many messageEndpointSink = Sinks.many().replay().latest(); + + /** + * Constructs a new SseServerTransport with the specified WebClient builder. Uses a + * default ObjectMapper instance for JSON processing. + * @param webClientBuilder the WebClient.Builder to use for creating the WebClient + * instance + * @throws IllegalArgumentException if webClientBuilder is null + */ + public SseServerTransport(WebClient.Builder webClientBuilder) { + this(webClientBuilder, new ObjectMapper()); + } + + /** + * Constructs a new SseServerTransport with the specified WebClient builder and + * ObjectMapper. Initializes both inbound and outbound message processing pipelines. + * @param webClientBuilder the WebClient.Builder to use for creating the WebClient + * instance + * @param objectMapper the ObjectMapper to use for JSON processing + * @throws IllegalArgumentException if either parameter is null + */ + public SseServerTransport(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + Assert.notNull(webClientBuilder, "WebClient.Builder must not be null"); + + this.objectMapper = objectMapper; + this.webClient = webClientBuilder.build(); + + this.outboundScheduler = Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), "outbound"); + + startInboundProcessing(); + startOutboundProcessing(); + } + + /** + * Initializes and starts the inbound SSE event processing. Establishes the SSE + * connection and sets up event handling for both message and endpoint events. + * Includes automatic retry logic for handling transient connection failures. + */ + private void startInboundProcessing() {// @formatter:off + this.inboundSubscription = this.webClient + .get() + .uri(SSE_ENDPOINT) + .accept(MediaType.TEXT_EVENT_STREAM) + .retrieve() + .bodyToFlux(SSE_TYPE) + .retryWhen(Retry.from(retrySignal -> retrySignal.handle(inboundRetryHandler))) + .subscribe( + onInboudEvent, + onInboundError, + onInboundComplete); + } // @formatter:on + + /** + * Retry handler for the inbound SSE stream. Implements the retry logic for handling + * connection failures and other errors. + */ + private BiConsumer> inboundRetryHandler = (retrySpec, sink) -> { + if (isClosing) { + logger.debug("SSE connection closed during shutdown"); + sink.error(retrySpec.failure()); + return; + } + if (retrySpec.failure() instanceof IOException) { + logger.debug("Retrying SSE connection after IO error"); + sink.next(retrySpec); + return; + } + logger.error("Fatal SSE error, not retrying: {}", retrySpec.failure().getMessage()); + sink.error(retrySpec.failure()); + }; + + /** + * Processes incoming SSE events, handling both message and endpoint events. For + * message events, deserializes and emits JSON-RPC messages to the inbound sink. For + * endpoint events, updates the message endpoint URI for outbound messages. + */ + private Consumer> onInboudEvent = event -> { + if (!isClosing) { + try { + logger.debug("Received SSE event: {}", event); + + // When a client connects, the server MUST send an endpoint event + // containing a URI for the client to use for sending messages. + // All subsequent client messages MUST be sent as HTTP POST requests + // to this endpoint. + if (ENDPOINT_EVENT_TYPE.equals(event.event())) { + + String messageEndpointUri = event.data(); + messageEndpointSink.tryEmitNext(messageEndpointUri); + + } + else if (MESSAGE_EVENT_TYPE.equals(event.event())) { + JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, event.data()); + + if (!this.getInboundSink().tryEmitNext(message).isSuccess()) { + throw new RuntimeException("Failed to enqueue message"); + } + } + } + catch (Exception e) { + if (!isClosing) { + throw new RuntimeException(e); + } + } + } + }; + + /** + * Error handler for the inbound SSE stream. + */ + private Consumer onInboundError = error -> { + if (!isClosing) { + logger.error("Error receiving SSE: {}", error); + } + }; + + /** + * Completion handler for the inbound SSE stream. + */ + private Runnable onInboundComplete = () -> { + if (!isClosing) { + logger.info("SSE stream completed!"); + } + }; + + /** + * Initializes and starts the outbound message processing. Sets up the pipeline for + * sending messages to the server-provided endpoint. Messages are sent as HTTP POST + * requests with JSON content. + * + *

    + * + * This pipeline depends on the inbound pipeline to provide the message endpoint URI + * for the client to use for sending messages. The messageEndpointSink is used to + * store the most recent message endpoint URI provided by the server. + */ + private void startOutboundProcessing() {// @formatter:off + this.outboundSubscription = messageEndpointSink.asFlux() + // The messageEndpoint is the endpoint URI to send the messages + // It is provided by the server as part of the endpoint event + .flatMap(messageEndpointUri -> this.getOutboundSink() + .asFlux() + .publishOn(outboundScheduler) + .flatMap(jsonRpcMessage -> { + if (isClosing) { + return Mono.empty(); + } + + try { + String jsonText = this.objectMapper.writeValueAsString(jsonRpcMessage); + + return webClient.post() + .uri(messageEndpointUri) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(jsonText) + .retrieve() + .toBodilessEntity() + .doOnSuccess(response -> { + logger.debug("Message sent successfully"); + }) + .doOnError(error -> { + if (!isClosing) { + logger.error("Error sending message: {}", error.getMessage()); + } + }) + .thenReturn(jsonRpcMessage); + } + catch (IOException e) { + if (!isClosing) { + return Mono.error(new RuntimeException("Failed to serialize message", e)); + } + return Mono.empty(); + } + })) + .subscribe(); + } // @formatter:on + + /** + * Implements graceful shutdown of the transport. Cleans up all resources including + * subscriptions and schedulers. Ensures orderly shutdown of both inbound and outbound + * message processing. + * @return a Mono that completes when shutdown is finished + */ + @Override + public Mono closeGracefully() { // @formatter:off + return Mono.fromRunnable(() -> { + isClosing = true; + + // Dispose of subscriptions + + if (inboundSubscription != null) { + inboundSubscription.dispose(); + } + + if (outboundSubscription != null) { + outboundSubscription.dispose(); + } + + // Dispose of scheduler + if (outboundScheduler != null) { + outboundScheduler.dispose(); + } + + // Complete the endpoint sink + // messageEndpointSink.tryEmitComplete(); + }) + .then() + .subscribeOn(Schedulers.boundedElastic()); + } // @formatter:on + +} diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/stdio/StdioServerTransport.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/stdio/StdioServerTransport.java index 3b28f00..394bdec 100644 --- a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/stdio/StdioServerTransport.java +++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/client/stdio/StdioServerTransport.java @@ -21,51 +21,64 @@ import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; -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 reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; import org.springframework.ai.mcp.client.util.Assert; import org.springframework.ai.mcp.spec.AbstractMcpTransport; +import org.springframework.ai.mcp.spec.McpSchema; import org.springframework.ai.mcp.spec.McpSchema.JSONRPCMessage; -import org.springframework.ai.mcp.spec.McpSchema.JSONRPCNotification; -import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest; -import org.springframework.ai.mcp.spec.McpSchema.JSONRPCResponse; /** - * Stdio client for communicating with a server process. + * Implementation of the MCP Stdio transport that communicates with a server process using + * standard input/output streams. Messages are exchanged as newline-delimited JSON-RPC + * messages over stdin/stdout, with errors and debug information sent to stderr. * * @author Christian Tzolov * @author Dariusz Jędrzejczyk */ public class StdioServerTransport extends AbstractMcpTransport { - private final static TypeReference> MAP_TYPE_REF = new TypeReference>() { - }; + private static final Logger logger = LoggerFactory.getLogger(StdioServerTransport.class); + /** The server process being communicated with */ private Process process; + /** Scheduler for handling inbound messages from the server process */ private Scheduler inboundScheduler; + /** Scheduler for handling outbound messages to the server process */ private Scheduler outboundScheduler; + /** Scheduler for handling error messages from the server process */ private Scheduler errorScheduler; + /** Parameters for configuring and starting the server process */ private final ServerParameters params; + /** + * Creates a new StdioServerTransport with the specified parameters and default + * ObjectMapper. + * @param params The parameters for configuring the server process + */ public StdioServerTransport(ServerParameters params) { this(params, new ObjectMapper()); } + /** + * Creates a new StdioServerTransport with the specified parameters and ObjectMapper. + * @param params The parameters for configuring the server process + * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization + */ public StdioServerTransport(ServerParameters params, ObjectMapper objectMapper) { - super(objectMapper); Assert.notNull(params, "The params can not be null"); @@ -79,6 +92,13 @@ public class StdioServerTransport extends AbstractMcpTransport { this.errorScheduler = Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), "error"); } + /** + * Starts the server process and initializes the message processing streams. This + * method sets up the process with the configured command, arguments, and environment, + * then starts the inbound, outbound, and error processing threads. + * @throws RuntimeException if the process fails to start or if the process streams + * are null + */ @Override public void start() { // Let's kick off the abstraction layer that will consume the logical messages @@ -115,10 +135,19 @@ public class StdioServerTransport extends AbstractMcpTransport { startErrorProcessing(); } + /** + * Creates and returns a new ProcessBuilder instance. Protected to allow overriding in + * tests. + * @return A new ProcessBuilder instance + */ protected ProcessBuilder getProcessBuilder() { return new ProcessBuilder(); } + /** + * Waits for the server process to exit. + * @throws RuntimeException if the process is interrupted while waiting + */ public void awaitForExit() { try { this.process.waitFor(); @@ -128,6 +157,10 @@ public class StdioServerTransport extends AbstractMcpTransport { } } + /** + * Starts the error processing thread that reads from the process's error stream. + * Error messages are logged and emitted to the error sink. + */ private void startErrorProcessing() { this.errorScheduler.schedule(() -> { try (BufferedReader processErrorReader = new BufferedReader( @@ -135,7 +168,7 @@ public class StdioServerTransport extends AbstractMcpTransport { String line; while ((line = processErrorReader.readLine()) != null) { try { - System.out.println("Received error line: " + line); + logger.error("Received error line: {}", line); // TODO: handle errors, etc. this.getErrorSink().tryEmitNext(line); } @@ -150,13 +183,17 @@ public class StdioServerTransport extends AbstractMcpTransport { }); } + /** + * Starts the inbound processing thread that reads JSON-RPC messages from the + * process's input stream. Messages are deserialized and emitted to the inbound sink. + */ private void startInboundProcessing() { this.inboundScheduler.schedule(() -> { try (BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = processReader.readLine()) != null) { try { - JSONRPCMessage message = deserializeJsonRpcMessage(line); + JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, line); if (!this.getInboundSink().tryEmitNext(message).isSuccess()) { // TODO: Back off, reschedule, give up? throw new RuntimeException("Failed to enqueue message"); @@ -173,24 +210,11 @@ public class StdioServerTransport extends AbstractMcpTransport { }); } - private JSONRPCMessage deserializeJsonRpcMessage(String jsonText) throws IOException { - - var map = this.objectMapper.readValue(jsonText, MAP_TYPE_REF); - - // Determine message type based on specific JSON structure - if (map.containsKey("method") && map.containsKey("id")) { - return this.objectMapper.convertValue(map, JSONRPCRequest.class); - } - else if (map.containsKey("method") && !map.containsKey("id")) { - return this.objectMapper.convertValue(map, JSONRPCNotification.class); - } - else if (map.containsKey("result") || map.containsKey("error")) { - return this.objectMapper.convertValue(map, JSONRPCResponse.class); - } - - throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + jsonText); - } - + /** + * Starts the outbound processing thread that writes JSON-RPC messages to the + * process's output stream. Messages are serialized to JSON and written with a newline + * delimiter. + */ private void startOutboundProcessing() { this.getOutboundSink() .asFlux() @@ -214,11 +238,16 @@ public class StdioServerTransport extends AbstractMcpTransport { .subscribe(); } + /** + * Gracefully closes the transport by destroying the process and disposing of the + * schedulers. This method sends a TERM signal to the process and waits for it to exit + * before cleaning up resources. + * @return A Mono that completes when the transport is closed + */ @Override public Mono closeGracefully() { - return Mono.fromFuture(() -> { - System.out.println("Sending TERM to process"); + logger.info("Sending TERM to process"); if (this.process != null) { this.process.destroy(); return process.onExit(); @@ -228,7 +257,7 @@ public class StdioServerTransport extends AbstractMcpTransport { } }).doOnNext(process -> { if (process.exitValue() != 0) { - System.out.println("Process terminated with code " + process.exitValue()); + logger.warn("Process terminated with code " + process.exitValue()); } }).then(Mono.fromRunnable(() -> { // The Threads are blocked on readLine so disposeGracefully would not @@ -239,4 +268,4 @@ public class StdioServerTransport extends AbstractMcpTransport { })).then().subscribeOn(Schedulers.boundedElastic()); } -} \ No newline at end of file +} diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/AbstractMcpTransport.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/AbstractMcpTransport.java index 5e26319..0f0556e 100644 --- a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/AbstractMcpTransport.java +++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/AbstractMcpTransport.java @@ -19,6 +19,8 @@ package org.springframework.ai.mcp.spec; import java.util.function.Consumer; import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; @@ -34,6 +36,8 @@ import org.springframework.ai.mcp.spec.McpSchema.JSONRPCMessage; // proper encapsulation and state management. public abstract class AbstractMcpTransport implements McpTransport { + private static final Logger logger = LoggerFactory.getLogger(AbstractMcpTransport.class); + protected final ObjectMapper objectMapper; private final Sinks.Many errorSink; @@ -42,10 +46,9 @@ public abstract class AbstractMcpTransport implements McpTransport { private final Sinks.Many outboundSink; - private Consumer inboundMessageHandler = message -> System.out - .println("Received message: " + message); + private Consumer inboundMessageHandler = message -> logger.debug("Received message: {}", message); - private Consumer errorHandler = error -> System.err.println("Received error: " + error); + private Consumer errorHandler = error -> logger.error("Received error: {}", error); public AbstractMcpTransport() { this(new ObjectMapper()); diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java index e8d3f4f..f145855 100644 --- a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java +++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java @@ -23,6 +23,8 @@ import java.util.concurrent.ConcurrentHashMap; 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 reactor.core.publisher.MonoSink; @@ -36,6 +38,8 @@ import org.springframework.ai.mcp.client.util.Assert; */ public class DefaultMcpSession implements McpSession { + private static final Logger logger = LoggerFactory.getLogger(DefaultMcpSession.class); + private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>(); private final Duration requestTimeout; @@ -59,21 +63,21 @@ public class DefaultMcpSession implements McpSession { if (message instanceof McpSchema.JSONRPCResponse response) { var sink = pendingResponses.remove(response.id()); if (sink == null) { - System.out.println("Unexpected response for unkown id " + response.id()); + logger.warn("Unexpected response for unkown id {}", response.id()); } else { sink.success(response); } } else if (message instanceof McpSchema.JSONRPCRequest request) { - System.out.println("Client does not yet support server requests"); + logger.info("Client does not yet support server requests"); } else if (message instanceof McpSchema.JSONRPCNotification notification) { - System.out.println("Notifications not yet supported"); + logger.info("Notifications not yet supported"); } }); - this.transport.setInboundErrorHandler(error -> System.out.println("Error received " + error)); + this.transport.setInboundErrorHandler(error -> logger.error("Error received: {}", error)); this.transport.start(); } diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java index 8eddbc8..f970cbe 100644 --- a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java +++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java @@ -16,6 +16,8 @@ package org.springframework.ai.mcp.spec; +import java.io.IOException; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -25,6 +27,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; /** * Based on the JSON-RPC 2.0 @@ -33,7 +37,6 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo.As; * Context Protocol Schema. * * @author Christian Tzolov - * @since 1.0.0 */ public class McpSchema { @@ -63,6 +66,38 @@ public class McpSchema { } + private final static TypeReference> MAP_TYPE_REF = new TypeReference<>() { + }; + + /** + * Deserializes a JSON string into a JSONRPCMessage object. + * @param objectMapper The ObjectMapper instance to use for deserialization + * @param jsonText The JSON string to deserialize + * @return A JSONRPCMessage instance using either the {@link JSONRPCRequest}, + * {@link JSONRPCNotification}, or {@link JSONRPCResponse} classes. + * @throws IOException If there's an error during deserialization + * @throws IllegalArgumentException If the JSON structure doesn't match any known + * message type + */ + public static JSONRPCMessage deserializeJsonRpcMessage(ObjectMapper objectMapper, String jsonText) + throws IOException { + + var map = objectMapper.readValue(jsonText, MAP_TYPE_REF); + + // Determine message type based on specific JSON structure + if (map.containsKey("method") && map.containsKey("id")) { + return objectMapper.convertValue(map, JSONRPCRequest.class); + } + else if (map.containsKey("method") && !map.containsKey("id")) { + return objectMapper.convertValue(map, JSONRPCNotification.class); + } + else if (map.containsKey("result") || map.containsKey("error")) { + return objectMapper.convertValue(map, JSONRPCResponse.class); + } + + throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + jsonText); + } + // --------------------------- // JSON-RPC Message Types // --------------------------- @@ -311,6 +346,7 @@ public class McpSchema { * actually be represented as text (not binary data). */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record TextResourceContents( // @formatter:off @JsonProperty("uri") String uri, @JsonProperty("mimeType") String mimeType, @@ -327,6 +363,7 @@ public class McpSchema { * (not text). */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record BlobResourceContents( // @formatter:off @JsonProperty("uri") String uri, @JsonProperty("mimeType") String mimeType, @@ -452,11 +489,11 @@ public class McpSchema { // Sampling and Message Creation @JsonInclude(JsonInclude.Include.NON_ABSENT) public record CreateMessageRequest(// @formatter:off - @JsonProperty("messages") List messages, + @JsonProperty("messages") List messages, @JsonProperty("modelPreferences") ModelPreferences modelPreferences, - @JsonProperty("systemPrompt") String systemPrompt, - @JsonProperty("includeContext") ContextInclusionStrategy includeContext, - @JsonProperty("temperature") Double temperature, + @JsonProperty("systemPrompt") String systemPrompt, + @JsonProperty("includeContext") ContextInclusionStrategy includeContext, + @JsonProperty("temperature") Double temperature, @JsonProperty("maxTokens") int maxTokens, @JsonProperty("stopSequences") List stopSequences, @JsonProperty("metadata") Map metadata) implements Request { @@ -506,14 +543,14 @@ public class McpSchema { // Progress and Logging // --------------------------- public record ProgressNotification(// @formatter:off - @JsonProperty("progressToken") String progressToken, - @JsonProperty("progress") double progress, + @JsonProperty("progressToken") String progressToken, + @JsonProperty("progress") double progress, @JsonProperty("total") Double total) { }// @formatter:on public record LoggingMessageNotification(// @formatter:off - @JsonProperty("level") LoggingLevel level, - @JsonProperty("logger") String logger, + @JsonProperty("level") LoggingLevel level, + @JsonProperty("logger") String logger, @JsonProperty("data") Object data) { }// @formatter:on @@ -528,25 +565,25 @@ public class McpSchema { } public record PromptReference(// @formatter:off - @JsonProperty("type") String type, + @JsonProperty("type") String type, @JsonProperty("name") String name) implements PromptOrResourceReference { }// @formatter:on public record ResourceReference(// @formatter:off - @JsonProperty("type") String type, + @JsonProperty("type") String type, @JsonProperty("uri") String uri) implements PromptOrResourceReference { }// @formatter:on public record CompleteArgument(// @formatter:off - @JsonProperty("name") String name, + @JsonProperty("name") String name, @JsonProperty("value") String value) { }// @formatter:on } public record CompleteResult(CompleteCompletion completion) { public record CompleteCompletion(// @formatter:off - @JsonProperty("values") List values, - @JsonProperty("total") Integer total, + @JsonProperty("values") List values, + @JsonProperty("total") Integer total, @JsonProperty("hasMore") Boolean hasMore) { }// @formatter:on } diff --git a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSession.java b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSession.java index b1b6279..533baa8 100644 --- a/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSession.java +++ b/spring-ai-mcp-core/src/main/java/org/springframework/ai/mcp/spec/McpSession.java @@ -16,7 +16,6 @@ package org.springframework.ai.mcp.spec; -import java.time.Duration; import java.util.Map; import com.fasterxml.jackson.core.type.TypeReference; diff --git a/spring-ai-mcp-core/src/main/resources/logback.xml b/spring-ai-mcp-core/src/main/resources/logback.xml new file mode 100644 index 0000000..8cea17b --- /dev/null +++ b/spring-ai-mcp-core/src/main/resources/logback.xml @@ -0,0 +1,31 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java new file mode 100644 index 0000000..735cf7b --- /dev/null +++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java @@ -0,0 +1,82 @@ +/* +* 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.attic; + +import java.time.Duration; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.ai.mcp.client.McpClient; +import org.springframework.ai.mcp.client.McpSyncClient; +import org.springframework.ai.mcp.client.sse.SseServerTransport; +import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest; +import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; +import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult; +import org.springframework.ai.mcp.spec.McpSchema.ListResourcesResult; +import org.springframework.ai.mcp.spec.McpSchema.ListToolsResult; +import org.springframework.ai.mcp.spec.McpSchema.Resource; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public class MainSSE { + + public static void main(String[] args) { + + try (McpSyncClient mcpClient = McpClient.sync( + new SseServerTransport(WebClient.builder().baseUrl("http://localhost:3001")), Duration.ofSeconds(1000), + new ObjectMapper())) { + + mcpClient.initialize(); + + ListToolsResult tools = mcpClient.listTools(); + System.out.println("Tools: " + tools); + + mcpClient.ping(); + + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", "Hello MCP Spring AI!")); + CallToolResult callToolResult = mcpClient.callTool(callToolRequest); + System.out.println("Call Tool Result: " + callToolResult); + + // mcpClient.sendRootsListChanged(); + + // Resources + ListResourcesResult resources = mcpClient.listResources(); + System.out.println("Resources Size: " + resources.resources().size()); + System.out.println("Resources: " + resources); + for (Resource resource : resources.resources()) { + System.out.println(mcpClient.readResource(resource)); + } + + var resourceTemplate = mcpClient.listResourceTemplates(); + System.out.println("Resource Templates: " + resourceTemplate); + + ListPromptsResult prompts = mcpClient.listPrompts(); + for (var prompt : prompts.prompts()) { + System.out.println("Prompt: " + prompt); + } + + } + catch (Exception e) { + e.printStackTrace(); + } + } + +} diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java similarity index 83% rename from spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientTests.java rename to spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java index ad8c1da..db6f139 100644 --- a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientTests.java +++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java @@ -23,46 +23,45 @@ 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 org.junit.jupiter.api.Timeout; import reactor.test.StepVerifier; -import org.springframework.ai.mcp.client.stdio.ServerParameters; -import org.springframework.ai.mcp.client.stdio.StdioServerTransport; import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest; import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest; import org.springframework.ai.mcp.spec.McpSchema.Prompt; import org.springframework.ai.mcp.spec.McpSchema.Resource; import org.springframework.ai.mcp.spec.McpSchema.Tool; +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; /** - * Unit tests for MCP Client Session functionality. + * Test suite for the {@link McpAsyncClient} that can be used with different + * {@link McpTransport} implementations. * * @author Christian Tzolov * @author Dariusz Jędrzejczyk */ -@Timeout(15) // Giving extra time beyond the client timeout -class McpAsyncClientTests { +public abstract class AbstractMcpAsyncClientTests { private McpAsyncClient mcpAsyncClient; - private ServerParameters stdioParams; + protected McpTransport mcpTransport; private static final Duration TIMEOUT = Duration.ofSeconds(20); - private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; + private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!"; + + abstract protected void createMcpTransport(); @BeforeEach void setUp() { - stdioParams = ServerParameters.builder("npx") - .args("-y", "@modelcontextprotocol/server-everything", "dir") - .build(); + + createMcpTransport(); assertThatCode(() -> { - mcpAsyncClient = McpClient.async(new StdioServerTransport(stdioParams), TIMEOUT, new ObjectMapper()); + mcpAsyncClient = McpClient.async(mcpTransport, TIMEOUT, new ObjectMapper()); mcpAsyncClient.initialize().block(Duration.ofSeconds(10)); }).doesNotThrowAnyException(); } @@ -80,10 +79,10 @@ class McpAsyncClientTests { assertThatThrownBy(() -> McpClient.sync(null, TIMEOUT, new ObjectMapper())) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> McpClient.sync(new StdioServerTransport(stdioParams), null, new ObjectMapper())) + assertThatThrownBy(() -> McpClient.sync(mcpTransport, null, new ObjectMapper())) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> McpClient.sync(new StdioServerTransport(stdioParams), TIMEOUT, null)) + assertThatThrownBy(() -> McpClient.sync(mcpTransport, TIMEOUT, null)) .isInstanceOf(IllegalArgumentException.class); } @@ -105,7 +104,7 @@ class McpAsyncClientTests { @Test void testCallTool() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); + CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); StepVerifier.create(mcpAsyncClient.callTool(callToolRequest)).consumeNextWith(callToolResult -> { assertThat(callToolResult).isNotNull().satisfies(result -> { @@ -117,7 +116,7 @@ class McpAsyncClientTests { @Test void testCallToolWithInvalidTool() { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); + CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", ECHO_TEST_MESSAGE)); assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class); } diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/McpSyncClientTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java similarity index 85% rename from spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/McpSyncClientTests.java rename to spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java index 545a4bb..14e309e 100644 --- a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/McpSyncClientTests.java +++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java @@ -23,10 +23,7 @@ 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 org.junit.jupiter.api.Timeout; -import org.springframework.ai.mcp.client.stdio.ServerParameters; -import org.springframework.ai.mcp.client.stdio.StdioServerTransport; import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest; import org.springframework.ai.mcp.spec.McpSchema.CallToolResult; import org.springframework.ai.mcp.spec.McpSchema.ListResourcesResult; @@ -34,6 +31,7 @@ import org.springframework.ai.mcp.spec.McpSchema.ListToolsResult; import org.springframework.ai.mcp.spec.McpSchema.Resource; import org.springframework.ai.mcp.spec.McpSchema.TextContent; import org.springframework.ai.mcp.spec.McpSchema.Tool; +import org.springframework.ai.mcp.spec.McpTransport; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -45,25 +43,24 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; * @author Christian Tzolov * @author Dariusz Jędrzejczyk */ -@Timeout(15) // Giving extra time beyond the client timeout -class McpSyncClientTests { +public abstract class AbstractMcpSyncClientTests { private McpSyncClient mcpSyncClient; - private ServerParameters stdioParams; - private static final Duration TIMEOUT = Duration.ofSeconds(20); private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; + protected McpTransport mcpTransport; + + abstract protected void createMcpTransport(); + @BeforeEach void setUp() { - stdioParams = ServerParameters.builder("npx") - .args("-y", "@modelcontextprotocol/server-everything", "dir") - .build(); + createMcpTransport(); assertThatCode(() -> { - mcpSyncClient = McpClient.sync(new StdioServerTransport(stdioParams), TIMEOUT, new ObjectMapper()); + mcpSyncClient = McpClient.sync(mcpTransport, TIMEOUT, new ObjectMapper()); mcpSyncClient.initialize(); }).doesNotThrowAnyException(); } @@ -80,10 +77,10 @@ class McpSyncClientTests { assertThatThrownBy(() -> McpClient.sync(null, TIMEOUT, new ObjectMapper())) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> McpClient.sync(new StdioServerTransport(stdioParams), null, new ObjectMapper())) + assertThatThrownBy(() -> McpClient.sync(mcpTransport, null, new ObjectMapper())) .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> McpClient.sync(new StdioServerTransport(stdioParams), TIMEOUT, null)) + assertThatThrownBy(() -> McpClient.sync(mcpTransport, TIMEOUT, null)) .isInstanceOf(IllegalArgumentException.class); } @@ -113,8 +110,6 @@ class McpSyncClientTests { assertThat(content).isNotNull(); assertThat(content.text()).isNotNull(); assertThat(content.text()).contains("7"); - - System.out.println(content); }); } diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpAsyncClientTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpAsyncClientTests.java new file mode 100644 index 0000000..71860dc --- /dev/null +++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpAsyncClientTests.java @@ -0,0 +1,63 @@ +/* + * 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.sse; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import org.springframework.ai.mcp.client.AbstractMcpAsyncClientTests; +import org.springframework.ai.mcp.client.McpAsyncClient; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Tests for the {@link McpAsyncClient} with {@link SseServerTransport}. + * + * @author Christian Tzolov + */ +@Timeout(15) // Giving extra time beyond the client timeout +class SseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { + + static String host = "http://localhost:3001"; + + // Uses the https://github.com/tzolov/mcp-everything-server-docker-image + @SuppressWarnings("resource") + static GenericContainer container = new GenericContainer<>("tzolov/mcp-everything-server:v1") + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withExposedPorts(3001) + .waitingFor(Wait.forHttp("/").forStatusCode(404)); + + @Override + protected void createMcpTransport() { + this.mcpTransport = new SseServerTransport(WebClient.builder().baseUrl(host)); + } + + @BeforeAll + static void beforeAll() { + container.start(); + int port = container.getMappedPort(3001); + host = "http://" + container.getHost() + ":" + port; + } + + @AfterAll + public static void cleanup() { + container.stop(); + } + +} diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpSyncClientTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpSyncClientTests.java new file mode 100644 index 0000000..7c7c32e --- /dev/null +++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpSyncClientTests.java @@ -0,0 +1,63 @@ +/* + * 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.sse; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Timeout; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import org.springframework.ai.mcp.client.AbstractMcpSyncClientTests; +import org.springframework.ai.mcp.client.McpSyncClient; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Tests for the {@link McpSyncClient} with {@link SseServerTransport}. + * + * @author Christian Tzolov + */ +@Timeout(15) // Giving extra time beyond the client timeout +class SseMcpSyncClientTests extends AbstractMcpSyncClientTests { + + static String host = "http://localhost:3001"; + + // Uses the https://github.com/tzolov/mcp-everything-server-docker-image + @SuppressWarnings("resource") + static GenericContainer container = new GenericContainer<>("tzolov/mcp-everything-server:v1") + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withExposedPorts(3001) + .waitingFor(Wait.forHttp("/").forStatusCode(404)); + + @Override + protected void createMcpTransport() { + this.mcpTransport = new SseServerTransport(WebClient.builder().baseUrl(host)); + } + + @BeforeAll + static void beforeAll() { + container.start(); + int port = container.getMappedPort(3001); + host = "http://" + container.getHost() + ":" + port; + } + + @AfterAll + public static void cleanup() { + container.stop(); + } + +} diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/sse/SseServerTransportTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/sse/SseServerTransportTests.java new file mode 100644 index 0000000..453fa93 --- /dev/null +++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/sse/SseServerTransportTests.java @@ -0,0 +1,335 @@ +/* + * 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.sse; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +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.test.StepVerifier; + +import org.springframework.ai.mcp.spec.McpSchema; +import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for the {@link SseServerTransport} class. + * + * @author Christian Tzolov + */ +@Disabled +@Timeout(15) +class SseServerTransportTests { + + static String host = "http://localhost:3001"; + + @SuppressWarnings("resource") + static GenericContainer container = new GenericContainer<>("tzolov/mcp-everything-server:v1") + .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) + .withExposedPorts(3001) + .waitingFor(Wait.forHttp("/").forStatusCode(404)); + + private TestSseServerTransport transport; + + private WebClient.Builder webClientBuilder; + + private ObjectMapper objectMapper; + + // Test class to access protected methods + static class TestSseServerTransport extends SseServerTransport { + + // private final Sinks.Many endpointSink = + // Sinks.many().replay().latest(); + + private final AtomicInteger inboundMessageCount = new AtomicInteger(0); + + public TestSseServerTransport(WebClient.Builder webClientBuilder, ObjectMapper objectMapper) { + super(webClientBuilder, objectMapper); + } + + // simulate receiving a message from the server + public void emitTestMessage(McpSchema.JSONRPCMessage message) { + getInboundSink().tryEmitNext(message); + inboundMessageCount.incrementAndGet(); + } + + public String getLastEndpoint() { + return messageEndpointSink.asFlux().blockFirst(); + } + + public int getInboundMessageCount() { + return inboundMessageCount.get(); + } + + public void simulateMessageEvent(String jsonMessage) { + try { + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(getObjectMapper(), jsonMessage); + getInboundSink().tryEmitNext(message); + inboundMessageCount.incrementAndGet(); + } + catch (Exception e) { + throw new RuntimeException("Failed to simulate message event", e); + } + } + + } + + @BeforeAll + static void beforeAll() { + container.start(); + int port = container.getMappedPort(3001); + host = "http://" + container.getHost() + ":" + port; + } + + @BeforeEach + void setUp() { + webClientBuilder = WebClient.builder().baseUrl(host); + objectMapper = new ObjectMapper(); + transport = new TestSseServerTransport(webClientBuilder, objectMapper); + } + + @AfterEach + void afterEach() { + if (transport != null) { + assertThatCode(() -> transport.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + } + } + + @AfterAll + static void cleanup() { + container.stop(); + } + + @Test + void testEndpointEventHandling() { + assertThat(transport.getLastEndpoint()).startsWith("/message?"); + } + + @Test + void constructorValidation() { + assertThatThrownBy(() -> new SseServerTransport(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("WebClient.Builder must not be null"); + + assertThatThrownBy(() -> new SseServerTransport(webClientBuilder, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ObjectMapper must not be null"); + } + + @Disabled + @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); + } + + @Disabled + @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); + } + + @Disabled + @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); + } + + @Disabled + @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); + } + + @Disabled + @Test + void testRetryBehavior() { + // Create a WebClient that simulates connection failures + WebClient.Builder failingWebClientBuilder = WebClient.builder().baseUrl("http://non-existent-host"); + + SseServerTransport failingTransport = new SseServerTransport(failingWebClientBuilder); + + // Verify that the transport attempts to reconnect + StepVerifier.create(Mono.delay(Duration.ofSeconds(2))).expectNextCount(1).verifyComplete(); + + // Clean up + failingTransport.closeGracefully().block(); + } + + @Disabled + @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); + } + + @Disabled + @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/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/stdio/McpAsyncClientTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/stdio/McpAsyncClientTests.java new file mode 100644 index 0000000..a303a18 --- /dev/null +++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/stdio/McpAsyncClientTests.java @@ -0,0 +1,40 @@ +/* + * 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.stdio; + +import org.junit.jupiter.api.Timeout; + +import org.springframework.ai.mcp.client.AbstractMcpAsyncClientTests; + +/** + * Tests for the {@link McpAsyncClient} with {@link StdioServerTransport}. + * + * @author Christian Tzolov + * @author Dariusz Jędrzejczyk + */ +@Timeout(15) // Giving extra time beyond the client timeout +class McpAsyncClientTests extends AbstractMcpAsyncClientTests { + + @Override + protected void createMcpTransport() { + ServerParameters stdioParams = ServerParameters.builder("npx") + .args("-y", "@modelcontextprotocol/server-everything", "dir") + .build(); + this.mcpTransport = new StdioServerTransport(stdioParams); + } + +} diff --git a/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/stdio/McpSyncClientTests.java b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/stdio/McpSyncClientTests.java new file mode 100644 index 0000000..37b06a0 --- /dev/null +++ b/spring-ai-mcp-core/src/test/java/org/springframework/ai/mcp/client/stdio/McpSyncClientTests.java @@ -0,0 +1,40 @@ +/* + * 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.stdio; + +import org.junit.jupiter.api.Timeout; + +import org.springframework.ai.mcp.client.AbstractMcpSyncClientTests; + +/** + * Tests for the {@link McpSyncClient} with {@link StdioServerTransport}. + * + * @author Christian Tzolov + * @author Dariusz Jędrzejczyk + */ +@Timeout(15) // Giving extra time beyond the client timeout +class McpSyncClientTests extends AbstractMcpSyncClientTests { + + @Override + protected void createMcpTransport() { + ServerParameters stdioParams = ServerParameters.builder("npx") + .args("-y", "@modelcontextprotocol/server-everything", "dir") + .build(); + this.mcpTransport = new StdioServerTransport(stdioParams); + } + +}