From 9d20ca8dc4b48c17b805411430dc3f0cc1438e14 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sat, 21 Dec 2024 18:19:01 +0100
Subject: [PATCH 001/514] Release version 0.2.0
---
mcp/pom.xml | 2 +-
pom.xml | 2 +-
spring-ai-mcp/pom.xml | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/mcp/pom.xml b/mcp/pom.xml
index 49be9b0..4fddcae 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.2.0-SNAPSHOT
+ 0.2.0
mcp
jar
diff --git a/pom.xml b/pom.xml
index b42a46f..6e7c1ee 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.2.0-SNAPSHOT
+ 0.2.0
pom
https://github.com/spring-projects-experimental/spring-ai-mcp
diff --git a/spring-ai-mcp/pom.xml b/spring-ai-mcp/pom.xml
index e0aee66..7a73466 100644
--- a/spring-ai-mcp/pom.xml
+++ b/spring-ai-mcp/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.2.0-SNAPSHOT
+ 0.2.0
spring-ai-mcp
Spring AI MCP
From c83a45f880796151eea6dbfca621197c4de0accf Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sat, 21 Dec 2024 18:19:21 +0100
Subject: [PATCH 002/514] Next development version
---
mcp/pom.xml | 2 +-
pom.xml | 2 +-
spring-ai-mcp/pom.xml | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/mcp/pom.xml b/mcp/pom.xml
index 4fddcae..c04e0b6 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.2.0
+ 0.3.0-SNAPSHOT
mcp
jar
diff --git a/pom.xml b/pom.xml
index 6e7c1ee..94e7b94 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.2.0
+ 0.3.0-SNAPSHOT
pom
https://github.com/spring-projects-experimental/spring-ai-mcp
diff --git a/spring-ai-mcp/pom.xml b/spring-ai-mcp/pom.xml
index 7a73466..1f8ddf3 100644
--- a/spring-ai-mcp/pom.xml
+++ b/spring-ai-mcp/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.2.0
+ 0.3.0-SNAPSHOT
spring-ai-mcp
Spring AI MCP
From abd0facf4e67fe028e02572495afe72183a64cbb Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Thu, 19 Dec 2024 19:38:38 +0100
Subject: [PATCH 003/514] feat: Add MCP server with Server stdio/sse transport
improve MCP client
- Implement MCP server with async/sync support and builder pattern configuration
- Add Server stdio/SSE transport layers with robust error handling
- Convert McpClient and McpServer to interfaces for better extensibility
- Add sampling capabilities for LLM interactions (text/image)
- Implement dynamic root management and runtime tool management
- Add comprehensive prompt and resource capabilities
- Add support for dynamic root management (add/remove roots at runtime)
Architecture Changes
- Introduce registration records for tools, resources, and prompts
- Improve thread safety with concurrent collections
- Enhance resource template handling
- Implement builder patterns for Server/Client capabilities
- Add support for server capabilities and implementation info
Transport Layer:
- Implement SseServerTransport with SSE endpoints
- Add StdioServerTransport for process-based communication
- Reorganize transport packages for better organization
- Improve transport error handling and shutdown processes
Documentation:
- Add detailed MCP reference documentation
- Include architecture diagrams and API documentation
- Add comprehensive test coverage for all components
- Include sample projects demonstrating stdio/SSE transports
BREAKING CHANGES:
Transport Package Reorganization
- Move StdioClientTransport to o.s.ai.mcp.client.transport
- Move SseClientTransport to o.s.ai.mcp.client.transport
- Move ServerParameters to o.s.ai.mcp.client.transport
- StdioClientTransport.errorHandler changed to private with
setter method Migration: Use setErrorHandler() method
instead of direct field access
API Changes
- Replace static factory methods with builder pattern
- Update ServerCapabilities to use builder pattern
- Make StdioClientTransport.errorHandler private with setter
- Replace rootsListProvider with direct root management
- Remove deprecated static factory methods
Resolves #27,#25,#24,#11
Related to #12
---
README.md | 6 +-
docs/MCP-SESSION.md | 78 ---
docs/MCP-layers.svg | 181 +++++++
docs/class-diagrams.puml | 110 ++++-
docs/ref-client.md | 299 ++++++++++++
docs/ref-errors.md | 154 ++++++
docs/ref-index.md | 91 ++++
docs/ref-server.md | 162 +++++++
docs/ref-session.md | 116 +++++
docs/ref-transport.md | 124 +++++
docs/spring-ai-mcp-uml-classdiagram.svg | 2 +-
mcp/README.md | 440 +++++++----------
mcp/pom.xml | 7 +
.../ai/mcp/client/McpAsyncClient.java | 160 +++++--
.../ai/mcp/client/McpClient.java | 88 ++--
.../ai/mcp/client/McpSyncClient.java | 24 +-
.../ServerParameters.java | 2 +-
.../SseClientTransport.java | 2 +-
.../StdioClientTransport.java | 6 +-
.../ai/mcp/server/McpAsyncServer.java | 444 ++++++++++++++++++
.../ai/mcp/server/McpServer.java | 330 +++++++++++++
.../ai/mcp/server/McpSyncServer.java | 138 ++++++
.../server/transport/SseServerTransport.java | 271 +++++++++++
.../transport/StdioServerTransport.java | 273 +++++++++++
.../ai/mcp/spec/DefaultMcpSession.java | 3 +
.../ai/mcp/spec/McpSchema.java | 120 ++++-
.../ai/mcp/attic/ClientSessionTests2.java | 4 +-
.../ai/mcp/attic/ClientSessionTests3.java | 4 +-
.../ai/mcp/attic/DemoServer.java | 61 +++
.../springframework/ai/mcp/attic/MainSSE.java | 2 +-
.../client/AbstractMcpAsyncClientTests.java | 146 +++++-
.../client/AbstractMcpSyncClientTests.java | 118 ++++-
.../McpAsyncClientResponseHandlerTests.java | 128 ++++-
.../{stdio => }/McpAsyncClientTests.java | 15 +-
.../{stdio => }/McpSyncClientTests.java | 9 +-
.../{sse => }/SseMcpAsyncClientTests.java | 5 +-
.../{sse => }/SseMcpSyncClientTests.java | 5 +-
.../SseClientTransportTests.java | 2 +-
.../server/AbstractMcpAsyncServerTests.java | 342 ++++++++++++++
.../server/AbstractMcpSyncServerTests.java | 314 +++++++++++++
.../ai/mcp/server/SseMcpAsyncServerTests.java | 64 +++
.../ai/mcp/server/SseMcpSyncServerTests.java | 64 +++
.../mcp/server/StdioMcpAsyncServerTests.java | 36 ++
.../mcp/server/StdioMcpSyncServerTests.java | 83 ++++
.../server/transport/BlockingInputStream.java | 81 ++++
.../transport/SseServerTransportTests.java | 283 +++++++++++
.../transport/StdioServerTransportTests.java | 174 +++++++
pom.xml | 1 +
spring-ai-mcp-sample/README.md | 257 ++++++++++
spring-ai-mcp-sample/pom.xml | 71 +++
.../ai/mcp/sample/client/ClientSse.java | 33 ++
.../ai/mcp/sample/client/ClientStdio.java | 39 ++
.../ai/mcp/sample/client/SampleClient.java | 80 ++++
.../sample/server/McpServerApplication.java | 13 +
.../ai/mcp/sample/server/McpServerConfig.java | 187 ++++++++
.../src/main/resources/application.properties | 16 +
spring-ai-mcp/README.md | 2 +-
57 files changed, 5763 insertions(+), 507 deletions(-)
delete mode 100644 docs/MCP-SESSION.md
create mode 100644 docs/MCP-layers.svg
create mode 100644 docs/ref-client.md
create mode 100644 docs/ref-errors.md
create mode 100644 docs/ref-index.md
create mode 100644 docs/ref-server.md
create mode 100644 docs/ref-session.md
create mode 100644 docs/ref-transport.md
rename mcp/src/main/java/org/springframework/ai/mcp/client/{stdio => transport}/ServerParameters.java (98%)
rename mcp/src/main/java/org/springframework/ai/mcp/client/{sse => transport}/SseClientTransport.java (99%)
rename mcp/src/main/java/org/springframework/ai/mcp/client/{stdio => transport}/StdioClientTransport.java (98%)
create mode 100644 mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java
create mode 100644 mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
create mode 100644 mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java
create mode 100644 mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java
create mode 100644 mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java
rename mcp/src/test/java/org/springframework/ai/mcp/client/{stdio => }/McpAsyncClientTests.java (85%)
rename mcp/src/test/java/org/springframework/ai/mcp/client/{stdio => }/McpSyncClientTests.java (86%)
rename mcp/src/test/java/org/springframework/ai/mcp/client/{sse => }/SseMcpAsyncClientTests.java (91%)
rename mcp/src/test/java/org/springframework/ai/mcp/client/{sse => }/SseMcpSyncClientTests.java (91%)
rename mcp/src/test/java/org/springframework/ai/mcp/client/{sse => transport}/SseClientTransportTests.java (99%)
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/server/transport/BlockingInputStream.java
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/server/transport/SseServerTransportTests.java
create mode 100644 mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java
create mode 100644 spring-ai-mcp-sample/README.md
create mode 100644 spring-ai-mcp-sample/pom.xml
create mode 100644 spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java
create mode 100644 spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java
create mode 100644 spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java
create mode 100644 spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java
create mode 100644 spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java
create mode 100644 spring-ai-mcp-sample/src/main/resources/application.properties
diff --git a/README.md b/README.md
index dce811b..330e838 100644
--- a/README.md
+++ b/README.md
@@ -42,14 +42,14 @@ Add the following dependencies to your Maven project:
org.springframework.experimental
mcp
- 0.2.0-SNAPSHOT
+ 0.3.0-SNAPSHOT
org.springframework.experimental
spring-ai-mcp
- 0.2.0-SNAPSHOT
+ 0.3.0-SNAPSHOT
```
@@ -85,7 +85,7 @@ mvn test
## Project Information
- **Group ID**: org.springframework.experimental
-- **Version**: 0.2.0-SNAPSHOT
+- **Version**: 0.3.0-SNAPSHOT
- **Java Version**: 17
- **Spring AI Version**: 1.0.0-M4
diff --git a/docs/MCP-SESSION.md b/docs/MCP-SESSION.md
deleted file mode 100644
index 7a48505..0000000
--- a/docs/MCP-SESSION.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# MCP Session Components
-
-## Overview
-
-The MCP (Model Context Protocol) session components implement the [MCP specification](https://spec.modelcontextprotocol.io/) for managing bidirectional JSON-RPC communication between clients and model servers.
-The implementation provides a robust foundation for request-response patterns and notification handling.
-
-## Core Components
-
-### McpSession Interface
-
-The `McpSession` interface defines the contract for MCP communication with three primary operations:
-
-1. Request-Response Communication
-```java
- Mono sendRequest(String method, Object requestParams, TypeReference typeRef);
-```
-
-2. Notification Handling
-```java
-Mono sendNotification(String method, Map params);
-```
-
-3. Session Lifecycle Management
-```java
-Mono closeGracefully();
-```
-
-### DefaultMcpSession Implementation
-
-The `DefaultMcpSession` provides the concrete implementation of the MCP session with the following key features:
-
-#### 1. Message Correlation
-- Uses unique request IDs combining session UUID prefix and atomic counter
-- Maintains thread-safe tracking of pending responses
-- Implements timeout management for requests
-
-#### 2. Handler Registration
-```java
-public interface RequestHandler {
- Mono handle(Object params);
-}
-
-public interface NotificationHandler {
- Mono handle(Object params);
-}
-```
-
-#### 3. Transport Integration
-- Abstracts transport details through `McpTransport` interface
-- Supports different transport implementations (SSE, STDIO)
-- Manages message serialization using Jackson
-
-## Implementation Details
-
-- The session processes three types of messages: McpSchema.JSONRPCResponse, McpSchema.JSONRPCRequest, McpSchema.JSONRPCNotificaiton.
-- The implementation ensures thread safety through
-
-### Error Handling
-
-WIP
-
-## Integration Guidelines
-
-1. **Transport Selection**
- - Choose appropriate transport (SSE, STDIO) based on use case
- - Configure transport-specific parameters
- - Handle transport lifecycle properly
-
-2. **Message Handling**
- - Register handlers before starting session
- - Use appropriate error codes from MCP specification
- - Implement proper request/response correlation
-
-3. **Type Safety**
- - Use appropriate TypeReference for response deserialization
- - Validate request parameters
- - Handle type conversion errors
diff --git a/docs/MCP-layers.svg b/docs/MCP-layers.svg
new file mode 100644
index 0000000..2f38d6a
--- /dev/null
+++ b/docs/MCP-layers.svg
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/class-diagrams.puml b/docs/class-diagrams.puml
index 4b2c8cd..e68059c 100644
--- a/docs/class-diagrams.puml
+++ b/docs/class-diagrams.puml
@@ -1,50 +1,82 @@
@startuml Core Components
+' Core Interfaces
interface McpTransport {
- +Mono connect(Function,
- Mono> handler)
- +Mono sendMessage()
+ +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()
- +close()
+ +void close()
}
+' Core Implementation Classes
class DefaultMcpSession {
-McpTransport transport
-ObjectMapper objectMapper
+ +interface RequestHandler
+ +interface NotificationHandler
}
+' Client Classes
class McpClient {
- +{static} async()
- +{static} sync()
+ +{static} Builder using(McpTransport transport)
+ +{static} McpAsyncClient async(McpTransport transport)
+ +{static} McpSyncClient sync(McpTransport transport)
}
class McpAsyncClient {
+Mono initialize()
- +Mono callTool()
+ +Mono callTool(CallToolRequest request)
+Mono listTools()
+Mono listResources()
- +Mono readResource()
+ +Mono readResource(ReadResourceRequest request)
+ +Mono listResourceTemplates()
+Mono listPrompts()
- +Mono getPrompt()
+ +Mono getPrompt(GetPromptRequest request)
}
class McpSyncClient {
+InitializeResult initialize()
- +CallToolResult callTool()
+ +CallToolResult callTool(CallToolRequest request)
+ListToolsResult listTools()
+ListResourcesResult listResources()
- +ReadResourceResult readResource()
+ +ReadResourceResult readResource(ReadResourceRequest request)
+ +ListResourceTemplatesResult listResourceTemplates()
+ListPromptsResult listPrompts()
- +GetPromptResult getPrompt()
+ +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
@@ -54,12 +86,21 @@ 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 {
@@ -67,13 +108,24 @@ class McpError {
-String message
}
-McpTransport <|-- StdioClientTransport
-McpTransport <|-- SseClientTransport
-McpSession <|-- DefaultMcpSession
+' 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
@@ -92,8 +144,11 @@ package "MCP Schema" {
class InitializeRequest
class CallToolRequest
- class CreateMessageRequest
- class CompleteRequest
+ class ListToolsRequest
+ class ListResourcesRequest
+ class ReadResourceRequest
+ class ListResourceTemplatesRequest
+ class ListPromptsRequest
class GetPromptRequest
}
@@ -113,13 +168,27 @@ package "Resource Types" {
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 <|.. CreateMessageRequest
-Request <|.. CompleteRequest
+Request <|.. ListToolsRequest
+Request <|.. ListResourcesRequest
+Request <|.. ReadResourceRequest
+Request <|.. ListResourceTemplatesRequest
+Request <|.. ListPromptsRequest
Request <|.. GetPromptRequest
ResourceContents <|.. TextResourceContents
@@ -129,4 +198,7 @@ Content <|.. TextContent
Content <|.. ImageContent
Content <|.. EmbeddedResource
+PromptOrResourceReference <|.. PromptReference
+PromptOrResourceReference <|.. ResourceReference
+
@enduml
diff --git a/docs/ref-client.md b/docs/ref-client.md
new file mode 100644
index 0000000..f0767bd
--- /dev/null
+++ b/docs/ref-client.md
@@ -0,0 +1,299 @@
+# 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
new file mode 100644
index 0000000..14d37c9
--- /dev/null
+++ b/docs/ref-errors.md
@@ -0,0 +1,154 @@
+# 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
new file mode 100644
index 0000000..13c860a
--- /dev/null
+++ b/docs/ref-index.md
@@ -0,0 +1,91 @@
+# 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
new file mode 100644
index 0000000..6daf3e9
--- /dev/null
+++ b/docs/ref-server.md
@@ -0,0 +1,162 @@
+# 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();
+```
+
+### Implementation Notes
+
+- The server factory uses a builder pattern for configuration
+- Default server info is "mcp-server" version "1.0.0" if not specified
+- 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
diff --git a/docs/ref-session.md b/docs/ref-session.md
new file mode 100644
index 0000000..8896dae
--- /dev/null
+++ b/docs/ref-session.md
@@ -0,0 +1,116 @@
+# 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
new file mode 100644
index 0000000..dd21da7
--- /dev/null
+++ b/docs/ref-transport.md
@@ -0,0 +1,124 @@
+# 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/docs/spring-ai-mcp-uml-classdiagram.svg b/docs/spring-ai-mcp-uml-classdiagram.svg
index 6b05be7..9636008 100644
--- a/docs/spring-ai-mcp-uml-classdiagram.svg
+++ b/docs/spring-ai-mcp-uml-classdiagram.svg
@@ -1 +1 @@
-McpTransport Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) Mono<Void> sendMessage() void close() Mono<Void> closeGracefully() McpSession <T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef) Mono<Void> sendNotification(String method, Map<String, Object> params) Mono<Void> closeGracefully() close() DefaultMcpSession McpTransport transport ObjectMapper objectMapper McpClient async() sync() McpAsyncClient Mono<InitializeResult> initialize() Mono<CallToolResult> callTool() Mono<ListToolsResult> listTools() Mono<ListResourcesResult> listResources() Mono<ReadResourceResult> readResource() Mono<ListPromptsResult> listPrompts() Mono<GetPromptResult> getPrompt() McpSyncClient InitializeResult initialize() CallToolResult callTool() ListToolsResult listTools() ListResourcesResult listResources() ReadResourceResult readResource() ListPromptsResult listPrompts() GetPromptResult getPrompt() StdioClientTransport Process serverProcess ServerParameters parameters SseClientTransport WebClient webClient McpSchema class ErrorCodes interface Request interface JSONRPCMessage interface ResourceContents interface Content McpError int code String message creates creates uses throws
\ No newline at end of file
+McpTransport Mono<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() DefaultMcpSession McpTransport transport ObjectMapper objectMapper interface RequestHandler interface NotificationHandler McpClient Builder using(McpTransport transport) McpAsyncClient async(McpTransport transport) McpSyncClient sync(McpTransport transport) McpAsyncClient Mono<InitializeResult> initialize() Mono<CallToolResult> callTool(CallToolRequest request) Mono<ListToolsResult> listTools() Mono<ListResourcesResult> listResources() Mono<ReadResourceResult> readResource(ReadResourceRequest request) Mono<ListResourceTemplatesResult> listResourceTemplates() Mono<ListPromptsResult> listPrompts() Mono<GetPromptResult> getPrompt(GetPromptRequest request) McpSyncClient InitializeResult initialize() CallToolResult callTool(CallToolRequest request) ListToolsResult listTools() ListResourcesResult listResources() ReadResourceResult readResource(ReadResourceRequest request) ListResourceTemplatesResult listResourceTemplates() ListPromptsResult listPrompts() GetPromptResult getPrompt(GetPromptRequest request) McpServer Builder using(McpTransport transport) McpAsyncServer interface ToolHandler Mono<Void> addTool(ToolHandler toolHandler) Mono<Void> removeTool(String toolName) Mono<Void> notifyToolsListChanged() Mono<Void> notifyResourcesListChanged() Mono<Void> notifyPromptsListChanged() McpSyncServer void addTool(ToolHandler toolHandler) void removeTool(String toolName) void notifyToolsListChanged() void notifyResourcesListChanged() void notifyPromptsListChanged() McpAsyncServer getAsyncServer() StdioClientTransport Process serverProcess ServerParameters parameters SseClientTransport WebClient webClient StdioServerTransport SseServerTransport McpSchema class ErrorCodes interface Request interface JSONRPCMessage interface ResourceContents interface Content interface ServerCapabilities JSONRPCMessage deserializeJsonRpcMessage() McpError int code String message creates creates creates creates uses throws
\ No newline at end of file
diff --git a/mcp/README.md b/mcp/README.md
index eef4e80..be0a690 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -8,23 +8,19 @@ This SDK implements the Model Context Protocol, enabling seamless integration wi
## Features
-- Synchronous and Asynchronous MCP Client implementations
-- Synchronous and Asynchronous MCP Server implementations (WIP: comming with 0.3.0)
+- Synchronous and Asynchronous MCP Client and MCP Server implementations
- Standard MCP operations support:
- Protocol version compatibility negotiation
- - Client-server capability exchange
- Tool discovery and execution with change notifications
- Tool list change notifications with non-blocking consumer support
- Resource management with URI templates
- Resource subscription system
- Roots list management and notifications
- Prompt handling and management
- - Server initialization and ping
- Multiple transport implementations:
- Stdio-based transport for process-based communication
- SSE-based transport for HTTP streaming
- Configurable request timeouts
-- Customizable JSON serialization/deserialization
## Installation
@@ -34,17 +30,116 @@ Add the following dependency to your Maven project:
org.springframework.experimental
mcp
- 0.2.0-SNAPSHOT
+ 0.3.0-SNAPSHOT
```
-## Usage
+## Architecture
-### Transport Layer Options
+The SDK follows a layered architecture with clear separation of concerns:
-The SDK provides two transport implementations:
+### Core Components Hierarchy
-#### StdioClientTransport
+
+
+- **Transport Layer (McpTransport)**: Handles JSON-RPC message serialization/deserialization via StdioTransport (stdin/stdout) and SseTransport (HTTP streaming).
+- **Session Layer (McpSession)**: Manages communication patterns and state using DefaultMcpSession implementation.
+- **Client/Server Layer**: Both use McpSession for sync/async operations, with McpClient handling client-side protocol operations and McpServer managing server-side protocol operations.
+
+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.
+
+
+
+
+### Key Interactions
+
+- **Client/Server Initialization**: Transport setup, protocol compatibility check, capability negotiation, and implementation details exchange.
+- **Message Flow**: JSON-RPC message handling with validation, type-safe response processing, and error handling.
+- **Resource Management**: Resource discovery, URI template-based access, subscription system, and content retrieval.
+- **Prompt System**: Discovery, parameter-based retrieval, change notifications, and content management.
+- **Tool Execution**: Discovery, parameter validation, timeout-aware execution, and result processing.
+
+
+### [Reference Documentation](../docs/ref-index.md)
+Detailed reference documentation covering the Spring AI implementation of the Model Context Protocol (MCP) specification.
+
+
+## Quick Start
+
+### McpClient
+
+Factory for creating `sync` and `async` variants MCP clients. Uses McpSession for communication and handles client-side protocol operations.
+
+#### Sync MCP Client
+
+```java
+// Create and initialize sync client
+McpTransport mcpTransport = ...
+
+try (McpSyncClient client = McpClient.using(mcpTransport).sync()) {
+ // Initialize connection with protocol version and capabilities
+ InitializeResult initResult = client.initialize();
+
+ // List available tools
+ ListToolsResult tools = client.listTools();
+
+ // Execute a tool
+ CallToolResult result = client.callTool(
+ new CallToolRequest("echo", Map.of("message", "Hello!"))
+ );
+
+ // Resource management
+ ListResourcesResult resources = client.listResources();
+ ReadResourceResult resource = client.readResource(
+ new ReadResourceRequest("resource-uri")
+ );
+
+ // List and retrieve prompts
+ ListPromptsResult prompts = client.listPrompts();
+ GetPromptResult prompt = client.getPrompt(
+ new GetPromptRequest("prompt-id", Map.of())
+ );
+}
+```
+
+#### Async MCP Client
+
+```java
+McpTransport mcpTransport = ...
+
+// Initialize async client with custom timeout
+McpAsyncClient client = McpClient.using(mcpTransport)
+ .requestTimeout(Duration.ofSeconds(30))
+ .async();
+
+// Initialize connection and chain operations
+var result = client.initialize()
+ // Connection initialized with protocol version compatibility
+ .flatMap(initResult -> client.listTools())
+ // Process available tools
+ .flatMap(tools -> client.callTool(
+ new CallToolRequest("echo", Map.of("message", "Hello MCP!"))))
+ // Handle tool execution result
+ .flatMap(toolResult -> client.listPrompts())
+ // Process available prompts
+ .flatMap(prompts -> client.getPrompt(new GetPromptRequest("prompt-id", Map.of()));
+
+// Subscribe to resource changes
+var subscription = client.listResources()
+ .flatMap(resources -> client.subscribeResource(new SubscribeRequest("resource-uri")));
+
+// Handle results reactively or block if needed
+GetPromptResult promptResult = result.block();
+subscription.block();
+
+// Cleanup
+client.closeGracefully().block();
+```
+
+#### Client Transports
+Clients can use the following transport options:
+
+##### StdioClientTransport
Standard I/O based transport for process-based communication with MCP servers:
```java
@@ -54,7 +149,7 @@ ServerParameters params = ServerParameters.builder("npx")
McpTransport transport = new StdioClientTransport(params);
```
-#### SseClientTransport
+##### SseClientTransport
Server-Sent Events (SSE) based transport following the MCP HTTP with SSE transport specification:
```java
@@ -63,279 +158,102 @@ WebClient.Builder webClientBuilder = WebClient.builder()
McpTransport transport = new SseClientTransport(webClientBuilder);
```
-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
+### MCP Server
-### Sync MCP Client Example
+Factory for creating `sync` and `async` variants MCP servers. Uses McpSession for communication and handles server-side protocol operations.
+
+#### MCP Sync Server
```java
-// Create and initialize sync client
-McpTransport mcpTransport = ...
+// Create a sync server with custom configuration
+McpSyncServer syncServer = McpServer.using(transport)
+ .serverInfo("my-server", "1.0.0")
+ .serverCapabilities(new ServerCapabilities(...))
+ .addTool(new CalculatorTool())
+ .resourcesProvider(cursor -> resources)
+ .promptsProvider(cursor -> prompts)
+ .sync();
-try (McpSyncClient client = McpClient.using(mcpTransport).sync()) {
- // Initialize connection with protocol version and capabilities
- McpSchema.InitializeResult initResult = client.initialize();
+// Add a tool handler at runtime
+syncServer.addTool(new CalculatorTool());
+logger.info("Tool added");
- // List available tools
- McpSchema.ListToolsResult tools = client.listTools();
+// Remove a tool handler
+syncServer.removeTool("calculator");
+logger.info("Tool removed");
- // Execute a tool
- McpSchema.CallToolResult result = client.callTool(
- new McpSchema.CallToolRequest("echo", Map.of("message", "Hello!"))
- );
+// Notify clients of changes
+syncServer.notifyToolsListChanged();
+syncServer.notifyResourcesListChanged();
+syncServer.notifyPromptsListChanged();
- // Resource management
- McpSchema.ListResourcesResult resources = client.listResources();
- McpSchema.ReadResourceResult resource = client.readResource(
- new McpSchema.ReadResourceRequest("resource-uri")
- );
-
- // List and retrieve prompts
- ListPromptsResult prompts = client.listPrompts();
- GetPromptResult prompt = client.getPrompt(
- new McpSchema.GetPromptRequest("prompt-id", Map.of())
- );
-}
+// Graceful shutdown
+syncServer.closeGracefully();
```
-### Async MCP Client Example with Custom Configuration
+#### MCP Async Server
```java
-McpTransport mcpTransport = ...
-
-// Initialize async client with custom timeout and object mapper
-McpAsyncClient client = McpClient.using(mcpTransport)
- .requestTimeout(Duration.ofSeconds(30))
+// Create an async server with custom configuration
+McpAsyncServer asyncServer = McpServer.using(transport)
+ .serverInfo("my-server", "1.0.0")
+ .serverCapabilities(new ServerCapabilities(...))
+ .addTool(new CalculatorTool())
+ .resourcesProvider(cursor -> resources)
+ .promptsProvider(cursor -> prompts)
.async();
-// Initialize connection and chain operations
-var result = client.initialize()
- .flatMap(initResult -> {
- // Connection initialized with protocol version compatibility
- return client.listTools();
- })
- .flatMap(tools -> {
- // Process available tools
- return client.callTool(new McpSchema.CallToolRequest("echo",
- Map.of("message", "Hello MCP!")));
- })
- .flatMap(toolResult -> {
- // Handle tool execution result
- return client.listPrompts();
- })
- .flatMap(prompts -> {
- // Process available prompts
- return client.getPrompt(new McpSchema.GetPromptRequest("prompt-id", Map.of()));
- });
-
-// Subscribe to resource changes
-var subscription = client.listResources()
- .flatMap(resources -> {
- return client.subscribeResource(new McpSchema.SubscribeRequest("resource-uri"));
- });
-
-// Set up tools change notification handling
-List>> toolsChangeConsumers = List.of(
- tools -> {
- // Handle tools list changes reactively
- tools.forEach(tool -> {
- System.out.println("Tool updated: " + tool.name());
- });
- }
-);
-
-McpAsyncClient clientWithToolsNotifications = McpClient.using(transport)
- .toolsChangeConsumer(toolsChangeConsumers)
- .async();
-
-// The client will now automatically handle tools/list_changed notifications
-// and invoke the consumers on the boundedElastic scheduler to avoid blocking
-
-// Handle results reactively or block if needed
-McpSchema.GetPromptResult promptResult = result.block();
-subscription.block();
-
-// Cleanup
-client.closeGracefully().block();
-```
-
-## Architecture
-
-The SDK follows a layered architecture with clear separation of concerns:
-
-### Core Components
-
-- **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 for client/server communication
-- **McpSchema**: Comprehensive protocol schema definitions
-- **DefaultMcpSession**: Default implementation of the session management
-- **StdioClientTransport**: Standard I/O based client to server communication
-- **SseClientTransport**: HTTP-based transport using Server-Sent Events for bidirectional client-sever communication
-
-
-
-### Key Interactions
-
-1. Client Initialization
- - Transport setup and connection establishment
- - Protocol version compatibility check
- - Capability negotiation
- - Implementation details exchange
-
-2. Message Flow
- - JSON-RPC message creation and validation
- - Transport layer handling
- - Response processing with type safety
- - Error handling with specific error codes
-
-3. Resource Management
- - Resource discovery and listing
- - URI template-based resource access
- - Subscription system for change notifications
- - Resource content retrieval
-
-4. Prompt System
- - Prompt discovery and listing
- - Parameter-based prompt retrieval
- - Change notifications support
- - Prompt content management
-
-5. Tool Execution
- - Tool discovery and capability checking
- - Parameter validation and processing
- - Execution handling with timeout support
- - Result processing with error handling
-
-### Roots List Support
-
-The SDK supports the MCP roots list capability, which allows servers to understand which directories and files they have access to. Clients can provide a list of root directories/files and notify servers when this list changes.
-
-#### Features
-- Define root providers that supply filesystem access boundaries
-- Support for roots list changed notifications
-- Automatic roots list request handling
-
-#### Example with Roots List Configuration
-
-```java
-// Create root providers
-List>> rootProviders = List.of(
- () -> List.of(new Root("file:///workspace/project", "Project Root")),
- () -> List.of(new Root("file:///workspace/docs", "Documentation"))
-);
-
-// Create async client with roots list support
-McpAsyncClient client = new McpAsyncClient(
- new StdioClientTransport(params),
- Duration.ofSeconds(30),
- rootProviders, // Configure root providers
- true // Enable roots list changed notifications
-);
-
-// Initialize connection
-client.initialize()
- .doOnSuccess(result -> {
- // Connection initialized with roots list capability
-
- // Notify server when roots list changes
- return client.sendRootsListChanged();
- })
+// Add a tool handler at runtime
+asyncServer.addTool(new CalculatorTool())
+ .doOnSuccess(v -> logger.info("Tool added"))
.subscribe();
-```
-### Change Notifications Support
-
-The SDK supports automatic handling of changes through a non-blocking notification system for tools, resources, and prompts:
-
-#### Features
-- Register multiple consumers for tools, resources, and prompts changes
-- Non-blocking execution using Project Reactor's boundedElastic scheduler
-- Automatic list request handling when notifications are received
-- Error resilient with proper error handling and logging
-
-#### Example with Change Notifications
-
-```java
-// Create tool change consumers
-List>> toolsChangeConsumers = List.of(
- tools -> {
- // First consumer - e.g., update UI
- tools.forEach(tool -> updateToolsUI(tool));
- },
- tools -> {
- // Second consumer - e.g., update cache
- toolsCache.updateTools(tools);
- }
-);
-
-// Create resource change consumers
-List>> resourcesChangeConsumers = List.of(
- resources -> {
- // Handle resource changes
- resources.forEach(resource -> {
- System.out.println("Resource updated: " + resource.uri());
- updateResourcesUI(resource);
- });
- }
-);
-
-// Create prompt change consumers
-List>> promptsChangeConsumers = List.of(
- prompts -> {
- // Handle prompt changes
- prompts.forEach(prompt -> {
- System.out.println("Prompt updated: " + prompt.name());
- updatePromptsCache(prompt);
- });
- }
-);
-
-// Create client with change notification support
-McpAsyncClient client = McpClient.using(transport)
- .toolsChangeConsumer(toolsChangeConsumer)
- .resourcesChangeConsumer(resourcesChangeConsumer)
- .promptsChangeConsumer(promptsChangeConsumer)
- .async();
-
-// Initialize client
-client.initialize()
- .doOnSuccess(result -> {
- // Client will automatically handle all change notifications
- // and invoke consumers non-blockingly on boundedElastic scheduler
- })
+// Remove a tool handler
+asyncServer.removeTool("calculator")
+ .doOnSuccess(v -> logger.info("Tool removed"))
.subscribe();
+
+// Notify clients of changes
+asyncServer.notifyToolsListChanged().subscribe();
+asyncServer.notifyResourcesListChanged().subscribe();
+asyncServer.notifyPromptsListChanged().subscribe();
+
+// Graceful shutdown
+asyncServer.closeGracefully().subscribe();
```
-The change notification system ensures that all consumers are executed non-blockingly, preventing any potential performance impact from blocking implementations. All consumers are executed on Project Reactor's boundedElastic scheduler, making it safe to perform potentially blocking operations within the consumers.
+#### Server Transports
-Each type of notification handler operates independently:
-- Tools: Handles notifications when available tools change
-- Resources: Handles notifications when available resources change
-- Prompts: Handles notifications when available prompts change
+Servers can use the following transport options:
+
+##### StdioServerTransport
+Standard I/O based transport for process-based communication:
+```java
+// Create transport with custom ObjectMapper
+ObjectMapper mapper = new ObjectMapper();
+StdioServerTransport transport = new StdioServerTransport(mapper);
+```
+
+Provides bidirectional JSON-RPC message handling over standard input/output streams with non-blocking message processing, serialization/deserialization, and graceful shutdown support.
+
+##### SseServerTransport
+Server-Sent Events based transport for HTTP streaming:
+```java
+// Create SSE transport
+ObjectMapper mapper = new ObjectMapper();
+String messageEndpoint = "/mcp/message";
+SseServerTransport transport = new SseServerTransport(mapper, messageEndpoint);
+
+// Get router function for web server configuration
+RouterFunction> router = 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.
## Error Handling
-The SDK provides comprehensive error handling through the McpError class:
-
-- Protocol version incompatibility
-- Transport-level communication errors
-- JSON-RPC protocol violations
-- Tool execution failures
-- Resource access and subscription errors
-- Prompt management errors
-- Request timeout handling
-- Server capability mismatches
-- SSE connection failures and retry handling
-- HTTP request/response errors
+The SDK provides comprehensive error handling through the McpError class, covering protocol compatibility, transport communication, JSON-RPC messaging, tool execution, resource management, prompt handling, timeouts, and connection issues. This unified error handling approach ensures consistent and reliable error management across both synchronous and asynchronous operations.
## Contributing
diff --git a/mcp/pom.xml b/mcp/pom.xml
index c04e0b6..eda6f43 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -68,6 +68,13 @@
test
+
+ org.springframework
+ spring-test
+ ${springframework.version}
+ test
+
+
org.assertj
assertj-core
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java
index 4af152e..fd39051 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java
@@ -20,8 +20,9 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
-import java.util.function.Supplier;
+import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference;
import org.slf4j.Logger;
@@ -34,8 +35,14 @@ import org.springframework.ai.mcp.spec.DefaultMcpSession.NotificationHandler;
import org.springframework.ai.mcp.spec.DefaultMcpSession.RequestHandler;
import org.springframework.ai.mcp.spec.McpError;
import org.springframework.ai.mcp.spec.McpSchema;
+import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
+import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities.RootCapabilities;
+import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities.Sampling;
+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.GetPromptResult;
+import org.springframework.ai.mcp.spec.McpSchema.Implementation;
import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
import org.springframework.ai.mcp.spec.McpSchema.PaginatedRequest;
import org.springframework.ai.mcp.spec.McpSchema.Root;
@@ -62,13 +69,38 @@ public class McpAsyncClient {
*/
private final DefaultMcpSession mcpSession;
+ /**
+ * Client capabilities.
+ */
+ private final McpSchema.ClientCapabilities clientCapabilities;
+
/**
* Roots define the boundaries of where servers can operate within the filesystem,
* allowing them to understand which directories and files they have access to.
* Servers can request the list of roots from supporting clients and receive
* notifications when that list changes.
*/
- private McpSchema.ClientCapabilities.RootCapabilities rootCapabilities;
+ private final ConcurrentHashMap roots;
+
+ /**
+ * MCP provides a standardized way for servers to request LLM sampling (“completions”
+ * or “generations”) from language models via clients. This flow allows clients to
+ * maintain control over model access, selection, and permissions while enabling
+ * servers to leverage AI capabilities—with no server API keys necessary. Servers can
+ * request text or image-based interactions and optionally include context from MCP
+ * servers in their prompts.
+ */
+ private Function samplingHandler;
+
+ /**
+ * Client transport implementation.
+ */
+ private final McpTransport transport;
+
+ /**
+ * Client implementation information.
+ */
+ private Implementation clientInfo;
/**
* Create a new McpAsyncClient with the given transport and session request-response
@@ -80,19 +112,35 @@ public class McpAsyncClient {
* @param rootsListChangedNotification whether the client supports roots/list_changed
* notification.
*/
- public McpAsyncClient(McpTransport transport, Duration requestTimeout,
- List>> rootsListProviders, boolean rootsListChangedNotification,
+ public McpAsyncClient(McpTransport transport, Duration requestTimeout, Implementation clientInfo,
+ ClientCapabilities clientCapabilities, Map roots,
List>> toolsChangeConsumers,
List>> resourcesChangeConsumers,
- List>> promptsChangeConsumers) {
+ List>> promptsChangeConsumers,
+ Function samplingHandler) {
+
+ this.clientInfo = clientInfo;
+
+ this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities
+ : new McpSchema.ClientCapabilities(null, !Utils.isEmpty(roots) ? new RootCapabilities(false) : null,
+ samplingHandler != null ? new Sampling() : null);
+
+ this.transport = transport;
+
+ this.roots = !Utils.isEmpty(roots) ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>();
// Request Handlers
Map requestHanlers = new HashMap<>();
// Roots List Request Handler
- if (rootsListProviders != null && !rootsListProviders.isEmpty()) {
- requestHanlers.put("roots/list", rootsListRequestHandler(rootsListProviders));
- this.rootCapabilities = new McpSchema.ClientCapabilities.RootCapabilities(rootsListChangedNotification);
+ if (!Utils.isEmpty(this.roots) && this.clientCapabilities.roots() != null) {
+ requestHanlers.put("roots/list", rootsListRequestHandler());
+ }
+
+ // Sampling Handler
+ if (samplingHandler != null && this.clientCapabilities.sampling() != null) {
+ this.samplingHandler = samplingHandler;
+ requestHanlers.put("sampling/createMessage", samplingCreateMessageHandler());
}
// Notification Handlers
@@ -129,30 +177,75 @@ public class McpAsyncClient {
}
- private RequestHandler rootsListRequestHandler(List>> rootsListProviders) {
- return new RequestHandler() {
- @Override
- public Mono handle(Object params) {
- if (rootsListProviders == null || rootsListProviders.isEmpty()) {
- return Mono.just(new ArrayList());
- }
+ public Mono addRoot(Root root) {
- List>> monos = rootsListProviders.stream()
- .map(supplier -> Mono.fromSupplier(supplier).subscribeOn(Schedulers.boundedElastic()))
- .toList();
+ if (root == null) {
+ return Mono.error(new McpError("Root must not be null"));
+ }
- return Mono.zip(monos, arrays -> {
- List combinedList = new ArrayList<>();
- for (Object array : arrays) {
- @SuppressWarnings("unchecked")
- List roots = (List) array;
- combinedList.addAll(roots);
- }
- return combinedList;
- });
+ if (this.clientCapabilities.roots() == null) {
+ return Mono.error(new McpError("Client must be configured with roots capabilities"));
+ }
+
+ if (this.roots.containsKey(root.uri())) {
+ return Mono.error(new McpError("Root with uri '" + root.uri() + "' already exists"));
+ }
+
+ this.roots.put(root.uri(), root);
+
+ logger.info("Added root: {}", root);
+
+ if (this.clientCapabilities.roots().listChanged()) {
+ return this.rootsListChangedNotification();
+ }
+ return Mono.empty();
+ }
+
+ public Mono removeRoot(String rootUri) {
+
+ if (rootUri == null) {
+ return Mono.error(new McpError("Root uri must not be null"));
+ }
+
+ if (this.clientCapabilities.roots() == null) {
+ return Mono.error(new McpError("Client must be configured with roots capabilities"));
+ }
+
+ Root removed = this.roots.remove(rootUri);
+
+ if (removed != null) {
+ logger.info("Removed Root: {}", rootUri);
+ if (this.clientCapabilities.roots().listChanged()) {
+ return this.rootsListChangedNotification();
}
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Root with uri '" + rootUri + "' not found"));
+ }
+
+ private RequestHandler samplingCreateMessageHandler() {
+ return params -> {
+ McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ CreateMessageResult response = this.samplingHandler.apply(request);
+
+ return Mono.just(response);
};
- };
+ }
+
+ private RequestHandler rootsListRequestHandler() {
+ return params -> {
+ McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ List roots = this.roots.values().stream().toList();
+
+ return Mono.just(roots);
+ };
+ }
private NotificationHandler toolsChangeNotificationHandler(
List>> toolsChangeConsumers) {
@@ -242,8 +335,8 @@ public class McpAsyncClient {
public Mono initialize() {
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
McpSchema.LATEST_PROTOCOL_VERSION,
- new McpSchema.ClientCapabilities(null, rootCapabilities, null),
- new McpSchema.Implementation("mcp-java-client", "0.2.0")); // @formatter:on
+ this.clientCapabilities,
+ this.clientInfo); // @formatter:on
Mono result = this.mcpSession.sendRequest("initialize", initializeRequest,
new TypeReference() {
@@ -268,15 +361,16 @@ public class McpAsyncClient {
/**
* Send a roots/list_changed notification.
*/
- public Mono sendRootsListChanged() {
+ public Mono rootsListChangedNotification() {
return this.mcpSession.sendNotification("notifications/roots/list_changed");
}
/**
* Send a synchronous ping request.
*/
- public Mono ping() {
- return this.mcpSession.sendRequest("ping", null, VOID_TYPE_REFERENCE);
+ public Mono ping() {
+ return this.mcpSession.sendRequest("ping", null, new TypeReference() {
+ });
}
// --------------------------
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
index 8fb211f..f0ef753 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
@@ -18,11 +18,17 @@ package org.springframework.ai.mcp.client;
import java.time.Duration;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.function.Consumer;
-import java.util.function.Supplier;
+import java.util.function.Function;
import org.springframework.ai.mcp.spec.McpSchema;
+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.Implementation;
import org.springframework.ai.mcp.spec.McpSchema.Root;
import org.springframework.ai.mcp.spec.McpTransport;
import org.springframework.ai.mcp.util.Assert;
@@ -51,14 +57,7 @@ import org.springframework.ai.mcp.util.Assert;
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
-public class McpClient {
-
- /**
- * Private constructor to prevent instantiation as this is a utility class containing
- * only static factory methods.
- */
- private McpClient() {
- }
+public interface McpClient {
/**
* Start building an MCP client with the specified transport.
@@ -78,9 +77,11 @@ public class McpClient {
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
- private boolean rootsListChangedNotification = false;
+ private ClientCapabilities capabilities;
- private List>> rootsListProviders = new ArrayList<>();
+ private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.0");
+
+ private Map roots = new HashMap<>();
private List>> toolsChangeConsumers = new ArrayList<>();
@@ -88,6 +89,8 @@ public class McpClient {
private List>> promptsChangeConsumers = new ArrayList<>();
+ private Function samplingHandler;
+
private Builder(McpTransport transport) {
Assert.notNull(transport, "Transport must not be null");
this.transport = transport;
@@ -104,13 +107,32 @@ public class McpClient {
return this;
}
- public Builder rootsListChangedNotification(boolean rootsListChangedNotification) {
- this.rootsListChangedNotification = rootsListChangedNotification;
+ public Builder capabilities(ClientCapabilities capabilities) {
+ this.capabilities = capabilities;
return this;
}
- public Builder rootsListProvider(Supplier> rootsListProvider) {
- this.rootsListProviders.add(rootsListProvider);
+ public Builder clientInfo(Implementation clientInfo) {
+ this.clientInfo = clientInfo;
+ return this;
+ }
+
+ public Builder roots(List roots) {
+ for (Root root : roots) {
+ this.roots.put(root.uri(), root);
+ }
+ return this;
+ }
+
+ public Builder roots(Root... roots) {
+ for (Root root : roots) {
+ this.roots.put(root.uri(), root);
+ }
+ return this;
+ }
+
+ public Builder sampling(Function samplingHandler) {
+ this.samplingHandler = samplingHandler;
return this;
}
@@ -142,42 +164,10 @@ public class McpClient {
* @return A new instance of {@link McpAsyncClient}
*/
public McpAsyncClient async() {
- return new McpAsyncClient(transport, requestTimeout, rootsListProviders, rootsListChangedNotification,
- toolsChangeConsumers, resourcesChangeConsumers, promptsChangeConsumers);
+ return new McpAsyncClient(transport, requestTimeout, clientInfo, capabilities, roots, toolsChangeConsumers,
+ resourcesChangeConsumers, promptsChangeConsumers, samplingHandler);
}
}
- /**
- * @deprecated Use {@link #using(McpTransport)} instead.
- */
- @Deprecated
- public static McpAsyncClient async(McpTransport transport) {
- return using(transport).async();
- }
-
- /**
- * @deprecated Use {@link #using(McpTransport)} instead.
- */
- @Deprecated
- public static McpAsyncClient async(McpTransport transport, Duration requestTimeout) {
- return using(transport).requestTimeout(requestTimeout).async();
- }
-
- /**
- * @deprecated Use {@link #using(McpTransport)} instead.
- */
- @Deprecated
- public static McpSyncClient sync(McpTransport transport) {
- return using(transport).sync();
- }
-
- /**
- * @deprecated Use {@link #using(McpTransport)} instead.
- */
- @Deprecated
- public static McpSyncClient sync(McpTransport transport, Duration requestTimeout) {
- return using(transport).requestTimeout(requestTimeout).sync();
- }
-
}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
index 6d1e964..18daf13 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
@@ -17,7 +17,6 @@
package org.springframework.ai.mcp.client;
import java.time.Duration;
-import java.util.concurrent.TimeoutException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -101,15 +100,30 @@ public class McpSyncClient implements AutoCloseable {
/**
* Send a roots/list_changed notification.
*/
- public void sendRootsListChanged() {
- this.delegate.sendRootsListChanged().block();
+ public void rootsListChangedNotification() {
+ this.delegate.rootsListChangedNotification().block();
+ }
+
+ /**
+ * Add a roots dynamically.
+ */
+ public void addRoot(McpSchema.Root root) {
+ this.delegate.addRoot(root).block();
+ }
+
+ /**
+ * Remove a root dynamically.
+ */
+ public void removeRoot(String rootUri) {
+ this.delegate.removeRoot(rootUri).block();
}
/**
* Send a synchronous ping request.
+ * @return
*/
- public void ping() {
- this.delegate.ping().block();
+ public Object ping() {
+ return this.delegate.ping().block();
}
// --------------------------
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/stdio/ServerParameters.java b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/ServerParameters.java
similarity index 98%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/stdio/ServerParameters.java
rename to mcp/src/main/java/org/springframework/ai/mcp/client/transport/ServerParameters.java
index d2b1702..a5c6858 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/stdio/ServerParameters.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/ServerParameters.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package org.springframework.ai.mcp.client.stdio;
+package org.springframework.ai.mcp.client.transport;
import java.util.ArrayList;
import java.util.Arrays;
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/sse/SseClientTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java
similarity index 99%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/sse/SseClientTransport.java
rename to mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java
index 7a16c2d..8438958 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/sse/SseClientTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.springframework.ai.mcp.client.sse;
+package org.springframework.ai.mcp.client.transport;
import java.io.IOException;
import java.util.function.BiConsumer;
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/stdio/StdioClientTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java
similarity index 98%
rename from mcp/src/main/java/org/springframework/ai/mcp/client/stdio/StdioClientTransport.java
rename to mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java
index e845a69..aa3f6b5 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/stdio/StdioClientTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package org.springframework.ai.mcp.client.stdio;
+package org.springframework.ai.mcp.client.transport;
import java.io.BufferedReader;
import java.io.IOException;
@@ -78,7 +78,7 @@ public class StdioClientTransport implements McpTransport {
private final Sinks.Many errorSink;
// visible for tests
- Consumer errorHandler = error -> logger.error("Error received: {}", error);
+ private Consumer errorHandler = error -> logger.error("Error received: {}", error);
/**
* Creates a new StdioClientTransport with the specified parameters and default
@@ -174,7 +174,7 @@ public class StdioClientTransport implements McpTransport {
*
* @param errorHandler a consumer that processes error messages
*/
- public void setInboundErrorHandler(Consumer errorHandler) {
+ public void setErrorHandler(Consumer errorHandler) {
this.errorHandler = errorHandler;
}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java
new file mode 100644
index 0000000..fc31c98
--- /dev/null
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java
@@ -0,0 +1,444 @@
+package org.springframework.ai.mcp.server;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import org.springframework.ai.mcp.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.DefaultMcpSession;
+import org.springframework.ai.mcp.spec.DefaultMcpSession.NotificationHandler;
+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.Tool;
+import org.springframework.ai.mcp.spec.McpTransport;
+import org.springframework.ai.mcp.util.Utils;
+
+/**
+ * The Model Context Protocol (MCP) server implementation that provides asynchronous
+ * communication.
+ *
+ * @author Christian Tzolov
+ */
+public class McpAsyncServer {
+
+ private final static Logger logger = LoggerFactory.getLogger(McpAsyncServer.class);
+
+ /**
+ * The MCP session implementation that manages bidirectional JSON-RPC communication
+ * between clients and servers.
+ */
+ private final DefaultMcpSession mcpSession;
+
+ private final McpTransport transport;
+
+ private final McpSchema.ServerCapabilities serverCapabilities;
+
+ private final McpSchema.Implementation serverInfo;
+
+ /**
+ * Thread-safe list of tool handlers that can be modified at runtime.
+ */
+ private final CopyOnWriteArrayList tools;
+
+ private final CopyOnWriteArrayList resourceTemplates;
+
+ private final ConcurrentHashMap resources;
+
+ private final ConcurrentHashMap prompts;
+
+ /**
+ * Create a new McpAsyncServer with the given transport and capabilities.
+ * @param transport The transport to use for communication
+ * @param tools List of tool registrations
+ * @param resources Map of resource registrations
+ * @param prompts Map of prompt registrations
+ */
+ public McpAsyncServer(McpTransport mcpTransport, McpSchema.Implementation serverInfo,
+ McpSchema.ServerCapabilities serverCapabilities, List tools,
+ Map resources, List resourceTemplates,
+ Map prompts) {
+
+ this.serverInfo = serverInfo;
+ this.tools = new CopyOnWriteArrayList<>(tools != null ? tools : List.of());
+ this.resources = !Utils.isEmpty(resources) ? new ConcurrentHashMap<>(resources) : new ConcurrentHashMap<>();
+ this.resourceTemplates = !Utils.isEmpty(resourceTemplates) ? new CopyOnWriteArrayList<>(resourceTemplates)
+ : new CopyOnWriteArrayList<>();
+ this.prompts = !Utils.isEmpty(prompts) ? new ConcurrentHashMap<>(prompts) : new ConcurrentHashMap<>();
+
+ this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities : new McpSchema.ServerCapabilities(
+ null, // experimental
+ null, // logging
+ !Utils.isEmpty(this.prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null,
+ !Utils.isEmpty(this.resources) ? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false)
+ : null,
+ !Utils.isEmpty(this.tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null);
+
+ Map requestHandlers = new HashMap<>();
+
+ // Initialize request handlers for standard MCP methods
+ requestHandlers.put("initialize", initializeRequestHandler());
+
+ // Ping MUST respond with an empty data, but not NULL response.
+ requestHandlers.put("ping", (params) -> Mono.just(""));
+
+ // Add tools API handlers if the tool capability is enabled
+ if (this.serverCapabilities.tools() != null) {
+ requestHandlers.put("tools/list", toolsListRequestHandler());
+ requestHandlers.put("tools/call", toolsCallRequestHandler());
+ }
+
+ // Add resources API handlers if provided
+ if (!Utils.isEmpty(this.resources)) {
+ requestHandlers.put("resources/list", resourcesListRequestHandler());
+ requestHandlers.put("resources/read", resourcesReadRequestHandler());
+ }
+
+ // Add resource templates API handlers if provided.
+ if (!Utils.isEmpty(this.resourceTemplates)) {
+ requestHandlers.put("resources/templates/list", resourceTemplateListRequestHandler());
+ }
+
+ // Add prompts API handlers if provider exists
+ if (!Utils.isEmpty(this.prompts)) {
+ requestHandlers.put("prompts/list", promptsListRequestHandler());
+ requestHandlers.put("prompts/get", promptsGetRequestHandler());
+ }
+
+ Map notificationHandlers = new HashMap<>();
+
+ notificationHandlers.put("notifications/initialized", (params) -> Mono.empty());
+
+ this.transport = mcpTransport;
+ this.mcpSession = new DefaultMcpSession(Duration.ofSeconds(10), mcpTransport, requestHandlers,
+ notificationHandlers);
+ }
+
+ /**
+ * Add a new tool registration at runtime.
+ * @param toolRegistration The tool registration to add
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono addTool(ToolRegistration toolRegistration) {
+ if (toolRegistration == null) {
+ return Mono.error(new McpError("Tool registration must not be null"));
+ }
+ if (toolRegistration.tool() == null) {
+ return Mono.error(new McpError("Tool must not be null"));
+ }
+ if (toolRegistration.call() == null) {
+ return Mono.error(new McpError("Tool call handler must not be null"));
+ }
+ if (this.serverCapabilities.tools() == null) {
+ return Mono.error(new McpError("Server must be configured with tool capabilities"));
+ }
+
+ // Check for duplicate tool names
+ if (this.tools.stream().anyMatch(th -> th.tool().name().equals(toolRegistration.tool().name()))) {
+ return Mono.error(new McpError("Tool with name '" + toolRegistration.tool().name() + "' already exists"));
+ }
+
+ this.tools.add(toolRegistration);
+ logger.info("Added tool handler: {}", toolRegistration.tool().name());
+ if (this.serverCapabilities.tools().listChanged()) {
+ return notifyToolsListChanged();
+ }
+ return Mono.empty();
+ }
+
+ /**
+ * Remove a tool handler at runtime.
+ * @param toolName The name of the tool handler to remove
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono removeTool(String toolName) {
+ if (toolName == null) {
+ return Mono.error(new McpError("Tool name must not be null"));
+ }
+ if (this.serverCapabilities.tools() == null) {
+ return Mono.error(new McpError("Server must be configured with tool capabilities"));
+ }
+
+ boolean removed = this.tools.removeIf(toolRegistration -> toolRegistration.tool().name().equals(toolName));
+ if (removed) {
+ logger.info("Removed tool handler: {}", toolName);
+ if (this.serverCapabilities.tools().listChanged()) {
+ return notifyToolsListChanged();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Tool with name '" + toolName + "' not found"));
+ }
+
+ /**
+ * Add a new resource handler at runtime.
+ * @param resourceHandler The resource handler to add
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono addResource(ResourceRegistration resourceHandler) {
+ if (resourceHandler == null || resourceHandler.resource() == null) {
+ return Mono.error(new McpError("Resource must not be null"));
+ }
+
+ if (this.serverCapabilities.resources() == null) {
+ return Mono.error(new McpError("Server must be configured with resource capabilities"));
+ }
+
+ if (this.resources.containsKey(resourceHandler.resource().uri())) {
+ return Mono
+ .error(new McpError("Resource with URI '" + resourceHandler.resource().uri() + "' already exists"));
+ }
+
+ this.resources.put(resourceHandler.resource().uri(), resourceHandler);
+ logger.info("Added resource handler: {}", resourceHandler.resource().uri());
+ if (this.serverCapabilities.resources().listChanged()) {
+ return notifyResourcesListChanged();
+ }
+ return Mono.empty();
+ }
+
+ /**
+ * Remove a resource handler at runtime.
+ * @param resourceUri The URI of the resource handler to remove
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono removeResource(String resourceUri) {
+ if (resourceUri == null) {
+ return Mono.error(new McpError("Resource URI must not be null"));
+ }
+ if (this.serverCapabilities.resources() == null) {
+ return Mono.error(new McpError("Server must be configured with resource capabilities"));
+ }
+
+ ResourceRegistration removed = this.resources.remove(resourceUri);
+ if (removed != null) {
+ logger.info("Removed resource handler: {}", resourceUri);
+ if (this.serverCapabilities.resources().listChanged()) {
+ return notifyResourcesListChanged();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found"));
+ }
+
+ /**
+ * Add a new prompt handler at runtime.
+ * @param promptRegistration The prompt handler to add
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono addPrompt(PromptRegistration promptRegistration) {
+ if (promptRegistration == null) {
+ return Mono.error(new McpError("Prompt registration must not be null"));
+ }
+ if (this.serverCapabilities.prompts() == null) {
+ return Mono.error(new McpError("Server must be configured with prompt capabilities"));
+ }
+
+ if (this.prompts.containsKey(promptRegistration.propmpt().name())) {
+ return Mono
+ .error(new McpError("Prompt with name '" + promptRegistration.propmpt().name() + "' already exists"));
+ }
+
+ this.prompts.put(promptRegistration.propmpt().name(), promptRegistration);
+ logger.info("Added prompt handler: {}", promptRegistration.propmpt().name());
+ if (this.serverCapabilities.prompts().listChanged()) {
+ return notifyPromptsListChanged();
+ }
+ return Mono.empty();
+ }
+
+ /**
+ * Remove a prompt handler at runtime.
+ * @param promptName The name of the prompt handler to remove
+ * @return Mono that completes when clients have been notified of the change
+ */
+ public Mono removePrompt(String promptName) {
+ if (promptName == null) {
+ return Mono.error(new McpError("Prompt name must not be null"));
+ }
+ if (this.serverCapabilities.prompts() == null) {
+ return Mono.error(new McpError("Server must be configured with prompt capabilities"));
+ }
+
+ PromptRegistration removed = this.prompts.remove(promptName);
+ if (removed != null) {
+ logger.info("Removed prompt handler: {}", promptName);
+ if (this.serverCapabilities.prompts().listChanged()) {
+ return this.notifyPromptsListChanged();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Prompt with name '" + promptName + "' not found"));
+ }
+
+ // ---------------------------------------
+ // Request Handlers
+ // ---------------------------------------
+ private DefaultMcpSession.RequestHandler initializeRequestHandler() {
+ return params -> {
+ McpSchema.InitializeRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ logger.info("Client initialize request - Protocol: {}, Capabilities: {}, Info: {}",
+ request.protocolVersion(), request.capabilities(), request.clientInfo());
+
+ if (!McpSchema.LATEST_PROTOCOL_VERSION.equals(request.protocolVersion())) {
+ return Mono
+ .error(
+ new McpError("Unsupported protocol version from client: " + request.protocolVersion()))
+ .publishOn(Schedulers.boundedElastic());
+ }
+
+ return Mono
+ .just(new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, this.serverCapabilities,
+ this.serverInfo, null))
+ .publishOn(Schedulers.boundedElastic());
+ };
+ }
+
+ private DefaultMcpSession.RequestHandler toolsListRequestHandler() {
+ return params -> {
+ McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ List tools = this.tools.stream().map(toolRegistration -> {
+ return toolRegistration.tool();
+ }).toList();
+
+ logger.info("Client tools list request - Cursor: {}", request.cursor());
+ return Mono.just(new McpSchema.ListToolsResult(tools, null));
+ };
+ }
+
+ private DefaultMcpSession.RequestHandler toolsCallRequestHandler() {
+ return params -> {
+ McpSchema.CallToolRequest callToolRequest = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ Optional toolRegistration = this.tools.stream()
+ .filter(tr -> callToolRequest.name().equals(tr.tool().name()))
+ .findAny();
+
+ if (toolRegistration.isEmpty()) {
+ return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
+ }
+
+ CallToolResult callResponse = toolRegistration.get().call().apply(callToolRequest.arguments());
+
+ return Mono.just(callResponse);
+ };
+ }
+
+ private DefaultMcpSession.RequestHandler resourcesListRequestHandler() {
+ return params -> {
+ McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ var resourceList = this.resources.values().stream().map(ResourceRegistration::resource).toList();
+
+ return Mono.just(new McpSchema.ListResourcesResult(resourceList, null));
+ };
+ }
+
+ private DefaultMcpSession.RequestHandler resourceTemplateListRequestHandler() {
+ return params -> {
+ McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ return Mono.just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
+ };
+ }
+
+ private DefaultMcpSession.RequestHandler resourcesReadRequestHandler() {
+ return params -> {
+ McpSchema.ReadResourceRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+ var resourceUri = request.uri();
+ if (this.resources.containsKey(resourceUri)) {
+ return Mono.just(this.resources.get(resourceUri).readHandler().apply(request));
+ }
+ return Mono.error(new McpError("Resource not found: " + resourceUri));
+ };
+ }
+
+ private DefaultMcpSession.RequestHandler promptsListRequestHandler() {
+ return params -> {
+ McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ var promptList = this.prompts.values().stream().map(PromptRegistration::propmpt).toList();
+
+ return Mono.just(new McpSchema.ListPromptsResult(promptList, null));
+ };
+ }
+
+ private DefaultMcpSession.RequestHandler promptsGetRequestHandler() {
+ return params -> {
+ McpSchema.GetPromptRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ // Implement prompt retrieval logic here
+ if (this.prompts.containsKey(request.name())) {
+ return Mono.just(this.prompts.get(request.name()).promptHandler().apply(request));
+ }
+
+ return Mono.error(new McpError("Prompt not found: " + request.name()));
+ };
+ }
+
+ /**
+ * Notify clients that the list of available tools has changed.
+ */
+ public Mono notifyToolsListChanged() {
+ return this.mcpSession.sendNotification("notifications/tools/list_changed", null);
+ }
+
+ /**
+ * Notify clients that the list of available resources has changed.
+ */
+ public Mono notifyResourcesListChanged() {
+ return this.mcpSession.sendNotification("notifications/resources/list_changed", null);
+ }
+
+ /**
+ * Notify clients that the list of available prompts has changed.
+ */
+ public Mono notifyPromptsListChanged() {
+ return this.mcpSession.sendNotification("notifications/prompts/list_changed", null);
+ }
+
+ /**
+ * Close the server gracefully.
+ */
+ public Mono closeGracefully() {
+ return this.mcpSession.closeGracefully();
+ }
+
+ /**
+ * Close the server immediately.
+ */
+ public void close() {
+ this.mcpSession.close();
+ }
+
+}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
new file mode 100644
index 0000000..4ab0bde
--- /dev/null
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
@@ -0,0 +1,330 @@
+/*
+ * 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.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import org.springframework.ai.mcp.spec.McpSchema;
+import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
+import org.springframework.ai.mcp.spec.McpSchema.ResourceTemplate;
+import org.springframework.ai.mcp.spec.McpSchema.Tool;
+import org.springframework.ai.mcp.spec.McpTransport;
+import org.springframework.ai.mcp.util.Assert;
+
+/**
+ * Factory class providing static methods for creating Model Context Protocol (MCP)
+ * servers. This class serves as the main entry point for establishing MCP servers,
+ * offering both synchronous and asynchronous server implementations.
+ *
+ *
+ * The class provides factory methods to create either:
+ *
+ * {@link McpAsyncServer} for non-blocking operations
+ * {@link McpSyncServer} for blocking operations
+ *
+ *
+ *
+ * Use the builder pattern for flexible server configuration:
+ *
+ *
{@code
+ * McpServer.using(transport)
+ * .serverInfo(new McpSchema.Implementation("my-server", "1.0.0"))
+ * .tool(new MyToolHandler())
+ * .async(); // or .sync()
+ * }
+ *
+ * @author Christian Tzolov
+ */
+public interface McpServer {
+
+ /**
+ * Registration of a tool with its handler function. MCP allows servers to expose
+ * tools that can be invoked by language models. Tools enable models to interact with
+ * external systems, such as querying databases, calling APIs, or performing
+ * computations. Each tool is uniquely identified by a name and includes metadata
+ * describing its schema.
+ *
+ * @param tool The tool definition
+ * @param call The function to handle tool execution
+ */
+ public static record ToolRegistration(Tool tool, Function, CallToolResult> call) {
+
+ }
+
+ public static record ResourceRegistration(McpSchema.Resource resource,
+ Function readHandler) {
+ }
+
+ public static record PromptRegistration(McpSchema.Prompt propmpt,
+ Function promptHandler) {
+ }
+
+ /**
+ * Start building an MCP server with the specified transport.
+ * @param transport The transport layer implementation for MCP communication
+ * @return A new builder instance
+ */
+ public static Builder using(McpTransport transport) {
+ return new Builder(transport);
+ }
+
+ /**
+ * Builder class for creating MCP servers with custom configuration.
+ */
+ public static class Builder {
+
+ private final McpTransport transport;
+
+ private McpSchema.Implementation serverInfo;
+
+ private McpSchema.ServerCapabilities serverCapabilities;
+
+ /**
+ * The Model Context Protocol (MCP) allows servers to expose tools that can be
+ * invoked by language models. Tools enable models to interact with external
+ * systems, such as querying databases, calling APIs, or performing computations.
+ * Each tool is uniquely identified by a name and includes metadata describing its
+ * schema.
+ */
+ private final List tools = new ArrayList<>();
+
+ /**
+ * The Model Context Protocol (MCP) provides a standardized way for servers to
+ * expose resources to clients. Resources allow servers to share data that
+ * provides context to language models, such as files, database schemas, or
+ * application-specific information. Each resource is uniquely identified by a
+ * URI.
+ */
+ private Map resources = new HashMap<>();
+
+ private List resourceTemplates = new ArrayList<>();
+
+ /**
+ * The Model Context Protocol (MCP) provides a standardized way for servers to
+ * expose prompt templates to clients. Prompts allow servers to provide structured
+ * messages and instructions for interacting with language models. Clients can
+ * discover available prompts, retrieve their contents, and provide arguments to
+ * customize them.
+ */
+ private Map prompts = new HashMap<>();
+
+ private Builder(McpTransport transport) {
+ Assert.notNull(transport, "Transport must not be null");
+ this.transport = transport;
+ }
+
+ /**
+ * Set the server implementation information.
+ * @param serverInfo The server implementation details
+ * @return This builder instance
+ */
+ public Builder info(McpSchema.Implementation serverInfo) {
+ Assert.notNull(serverInfo, "Server info must not be null");
+ this.serverInfo = serverInfo;
+ return this;
+ }
+
+ /**
+ * Set the server implementation information using name and version.
+ * @param name The server name
+ * @param version The server version
+ * @return This builder instance
+ */
+ public Builder info(String name, String version) {
+ Assert.hasText(name, "Name must not be null or empty");
+ Assert.hasText(version, "Version must not be null or empty");
+ this.serverInfo = new McpSchema.Implementation(name, version);
+ return this;
+ }
+
+ /**
+ * Set the server implementation information using name and version.
+ * @param name The server name
+ * @param version The server version
+ * @return This builder instance
+ */
+ public Builder serverInfo(String name, String version) {
+ Assert.notNull(name, "Server name must not be null");
+ Assert.notNull(version, "Server version must not be null");
+ this.serverInfo = new McpSchema.Implementation(name, version);
+ return this;
+ }
+
+ /**
+ * Set the server capabilities.
+ * @param serverCapabilities The server capabilities configuration
+ * @return This builder instance
+ */
+ public Builder capabilities(McpSchema.ServerCapabilities serverCapabilities) {
+ this.serverCapabilities = serverCapabilities;
+ return this;
+ }
+
+ /**
+ * Add a tool with its handler function.
+ * @param tool The tool definition
+ * @param handler The function to handle tool execution
+ * @return This builder instance
+ */
+ public Builder tool(McpSchema.Tool tool, Function, McpSchema.CallToolResult> handler) {
+ Assert.notNull(tool, "Tool must not be null");
+ Assert.notNull(handler, "Handler must not be null");
+
+ this.tools.add(new ToolRegistration(tool, handler));
+
+ return this;
+ }
+
+ /**
+ * Add multiple tool handlers to the server.
+ * @param toolRegistrations The list of tool handlers to add
+ * @return This builder instance
+ */
+ public Builder tools(List toolRegistrations) {
+ Assert.notNull(toolRegistrations, "Tool handlers list must not be null");
+ this.tools.addAll(toolRegistrations);
+ return this;
+ }
+
+ /**
+ * Register tools with the server.
+ * @param toolRegistrations The map of tools to register
+ * @return This builder instance
+ */
+ public Builder tools(ToolRegistration... toolRegistrations) {
+ for (ToolRegistration tool : toolRegistrations) {
+ this.tools.add(tool);
+ }
+ return this;
+ }
+
+ /**
+ * Register resources with the server.
+ * @param resourceRegsitrations The map of resources to register
+ * @return This builder instance
+ */
+ public Builder resources(Map resourceRegsitrations) {
+ Assert.notNull(resourceRegsitrations, "Resource handlers map must not be null");
+ this.resources.putAll(resourceRegsitrations);
+ return this;
+ }
+
+ /**
+ * Register resources with the server.
+ * @param resourceRegsitrations The list of resources to register
+ * @return This builder instance
+ */
+ public Builder resources(List resourceRegsitrations) {
+ Assert.notNull(resourceRegsitrations, "Resource handlers list must not be null");
+ for (ResourceRegistration resource : resourceRegsitrations) {
+ this.resources.put(resource.resource().name(), resource);
+ }
+ return this;
+ }
+
+ /**
+ * Register resources with the server.
+ * @param resourceRegistrations The list of resources to register
+ * @return This builder instance
+ */
+ public Builder resources(ResourceRegistration... resourceRegistrations) {
+ Assert.notNull(resourceRegistrations, "Resource handlers list must not be null");
+ for (ResourceRegistration resource : resourceRegistrations) {
+ this.resources.put(resource.resource().name(), resource);
+ }
+ return this;
+ }
+
+ /**
+ * Set the resource templates.
+ * @param resourceTemplates The list of resource templates
+ * @return This builder instance
+ */
+ public Builder resourceTemplates(List resourceTemplates) {
+ this.resourceTemplates = resourceTemplates;
+ return this;
+ }
+
+ public Builder resourceTemplates(ResourceTemplate... resourceTemplates) {
+ for (ResourceTemplate resourceTemplate : resourceTemplates) {
+ this.resourceTemplates.add(resourceTemplate);
+ }
+ return this;
+ }
+
+ /**
+ * Register prompts with the server.
+ * @param prompts The map of prompts to register
+ * @return This builder instance
+ */
+ public Builder prompts(Map prompts) {
+ this.prompts.putAll(prompts);
+ return this;
+ }
+
+ /**
+ * Register prompts with the server.
+ * @param prompts The list of prompts to register
+ * @return This builder instance.
+ */
+ public Builder prompts(List prompts) {
+ for (PromptRegistration prompt : prompts) {
+ this.prompts.put(prompt.propmpt().name(), prompt);
+ }
+ return this;
+ }
+
+ /**
+ * Register prompts with the server.
+ * @param prompts The list of prompts to register
+ * @return This builder instance
+ */
+ public Builder prompts(PromptRegistration... prompts) {
+ for (PromptRegistration prompt : prompts) {
+ this.prompts.put(prompt.propmpt().name(), prompt);
+ }
+ return this;
+ }
+
+ /**
+ * Build a synchronous MCP server.
+ * @return A new instance of {@link McpSyncServer}
+ */
+ public McpSyncServer sync() {
+ return new McpSyncServer(async());
+ }
+
+ /**
+ * Build an asynchronous MCP server.
+ * @return A new instance of {@link McpAsyncServer}
+ */
+ public McpAsyncServer async() {
+ if (serverInfo == null) {
+ serverInfo = new McpSchema.Implementation("mcp-server", "1.0.0");
+ }
+
+ return new McpAsyncServer(transport, serverInfo, serverCapabilities, tools, resources, resourceTemplates,
+ prompts);
+ }
+
+ }
+
+}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java
new file mode 100644
index 0000000..df13512
--- /dev/null
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java
@@ -0,0 +1,138 @@
+/*
+ * 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 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.util.Assert;
+
+/**
+ * Synchronous wrapper around {@link McpAsyncServer} that provides blocking operations.
+ * This class delegates all operations to an underlying async server instance while
+ * providing a synchronous API.
+ *
+ * @author Christian Tzolov
+ */
+public class McpSyncServer {
+
+ /**
+ * The async server to wrap.
+ */
+ private final McpAsyncServer asyncServer;
+
+ /**
+ * Creates a new synchronous server that wraps the provided async server.
+ * @param asyncServer The async server to wrap
+ */
+ public McpSyncServer(McpAsyncServer asyncServer) {
+ Assert.notNull(asyncServer, "Async server must not be null");
+ this.asyncServer = asyncServer;
+ }
+
+ /**
+ * Add a new tool handler.
+ * @param toolHandler The tool handler to add
+ */
+ public void addTool(ToolRegistration toolHandler) {
+ this.asyncServer.addTool(toolHandler).block();
+ }
+
+ /**
+ * Remove a tool handler.
+ * @param toolName The name of the tool handler to remove
+ */
+ public void removeTool(String toolName) {
+ this.asyncServer.removeTool(toolName).block();
+ }
+
+ /**
+ * Notify clients that the list of available tools has changed.
+ */
+ public void notifyToolsListChanged() {
+ this.asyncServer.notifyToolsListChanged().block();
+ }
+
+ /**
+ * Notify clients that the list of available resources has changed.
+ */
+ public void notifyResourcesListChanged() {
+ this.asyncServer.notifyResourcesListChanged().block();
+ }
+
+ /**
+ * Notify clients that the list of available prompts has changed.
+ */
+ public void notifyPromptsListChanged() {
+ this.asyncServer.notifyPromptsListChanged().block();
+ }
+
+ /**
+ * Add a new resource handler.
+ * @param resourceHandler The resource handler to add
+ */
+ public void addResource(ResourceRegistration resourceHandler) {
+ this.asyncServer.addResource(resourceHandler).block();
+ }
+
+ /**
+ * Remove a resource handler.
+ * @param resourceUri The URI of the resource handler to remove
+ */
+ public void removeResource(String resourceUri) {
+ this.asyncServer.removeResource(resourceUri).block();
+ }
+
+ /**
+ * Add a new prompt handler.
+ * @param promptRegistration The prompt registration to add
+ */
+ public void addPrompt(PromptRegistration promptRegistration) {
+ this.asyncServer.addPrompt(promptRegistration).block();
+ }
+
+ /**
+ * Remove a prompt handler.
+ * @param promptName The name of the prompt handler to remove
+ */
+ public void removePrompt(String promptName) {
+ this.asyncServer.removePrompt(promptName).block();
+ }
+
+ /**
+ * Close the server gracefully.
+ */
+ public void closeGracefully() {
+ this.asyncServer.closeGracefully().block();
+ }
+
+ /**
+ * Close the server immediately.
+ */
+ public void close() {
+ this.asyncServer.close();
+ }
+
+ /**
+ * Get the underlying async server instance.
+ * @return The wrapped async server
+ */
+ public McpAsyncServer getAsyncServer() {
+ return this.asyncServer;
+ }
+
+}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java
new file mode 100644
index 0000000..8125f4d
--- /dev/null
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java
@@ -0,0 +1,271 @@
+package org.springframework.ai.mcp.server.transport;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+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.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+
+import org.springframework.ai.mcp.spec.McpError;
+import org.springframework.ai.mcp.spec.McpSchema;
+import org.springframework.ai.mcp.spec.McpTransport;
+import org.springframework.ai.mcp.util.Assert;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.codec.ServerSentEvent;
+import org.springframework.web.reactive.function.server.RouterFunction;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.reactive.function.server.ServerResponse;
+
+/**
+ * Server-side implementation of the MCP HTTP with SSE transport specification. Provides
+ * endpoints for SSE connections and message handling.
+ *
+ * @author Christian Tzolov
+ */
+public class SseServerTransport implements McpTransport {
+
+ private final static Logger logger = LoggerFactory.getLogger(SseServerTransport.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 SseServerTransport.
+ * @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) {
+ 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().then();
+ }
+
+ @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 {// @formatter:off
+ String jsonText = objectMapper.writeValueAsString(message);
+ ServerSentEvent event = ServerSentEvent.builder()
+ .event(MESSAGE_EVENT_TYPE)
+ .data(jsonText)
+ .build();
+
+ logger.debug("Attempting to broadcast message to {} active sessions", sessions.size());
+
+ List failedSessions = sessions.values().stream()
+ .filter(session -> session.messageSink.tryEmitNext(event).isFailure())
+ .map(session -> session.id)
+ .toList();
+
+ if (failedSessions.isEmpty()) {
+ logger.debug("Successfully broadcast message to all sessions");
+ sink.success();
+ }
+ else {
+ String error = "Failed to broadcast message to sessions: " + String.join(", ", failedSessions);
+ logger.error(error);
+ sink.error(new RuntimeException(error));
+ } // @formatter:on
+ }
+ catch (IOException e) {
+ logger.error("Failed to serialize message: {}", e.getMessage());
+ sink.error(e);
+ }
+ });
+ }
+
+ @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());
+ }).then(Mono.when(sessions.values().stream().map(session -> {
+ String sessionId = session.id;
+ return Mono.fromRunnable(() -> session.close())
+ .then(Mono.delay(Duration.ofMillis(100)))
+ .then(Mono.fromRunnable(() -> sessions.remove(sessionId)));
+ }).toList()))
+ .timeout(Duration.ofSeconds(5))
+ .doOnSuccess(v -> logger.info("Graceful shutdown completed"))
+ .doOnError(e -> logger.error("Error during graceful shutdown: {}", e.getMessage()));
+ }
+
+ /**
+ * Get the router function for configuring the web server.
+ */
+ public RouterFunction> getRouterFunction() {
+ return this.routerFunction;
+ }
+
+ /**
+ * Handles new SSE connection requests from clients.
+ */
+ private Mono handleSseConnection(ServerRequest request) {
+ if (isClosing) {
+ return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("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);
+
+ return ServerResponse.ok()
+ .contentType(MediaType.TEXT_EVENT_STREAM)
+ .body(Flux.>create(sink -> {
+ // Send initial endpoint event
+ logger.debug("Sending initial endpoint event to session: {}", sessionId);
+ sink.next(ServerSentEvent.builder().event(ENDPOINT_EVENT_TYPE).data(messageEndpoint).build());
+
+ // Subscribe to session messages
+ session.messageSink.asFlux()
+ .doOnSubscribe(s -> logger.debug("Session {} subscribed to message sink", sessionId))
+ .doOnComplete(() -> {
+ logger.debug("Session {} completed", sessionId);
+ sessions.remove(sessionId);
+ })
+ .doOnError(error -> {
+ logger.error("Error in session {}: {}", sessionId, error.getMessage());
+ sessions.remove(sessionId);
+ })
+ .doOnCancel(() -> {
+ logger.debug("Session {} cancelled", sessionId);
+ sessions.remove(sessionId);
+ })
+ .subscribe(event -> {
+ logger.debug("Forwarding event to session {}: {}", sessionId, event);
+ sink.next(event);
+ }, sink::error, sink::complete);
+
+ sink.onCancel(() -> {
+ logger.debug("Session {} cancelled", sessionId);
+ sessions.remove(sessionId);
+ });
+ }), ServerSentEvent.class);
+ }
+
+ /**
+ * Handles incoming messages from clients.
+ */
+ private Mono handleMessage(ServerRequest request) {
+ if (isClosing) {
+ return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
+ }
+
+ return request.bodyToMono(String.class).flatMap(body -> {
+ try {
+ McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
+ return Mono.just(message)
+ .transform(this.connectHandler)
+ .flatMap(response -> ServerResponse.ok().build())
+ .onErrorResume(error -> {
+ logger.error("Error processing message: {}", error.getMessage());
+ return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .bodyValue(new McpError(error.getMessage()));
+ });
+ }
+ catch (IllegalArgumentException e) {
+ logger.error("Failed to deserialize message: {}", e.getMessage());
+ return ServerResponse.badRequest().bodyValue(new McpError("Invalid message format"));
+ }
+ catch (IOException e) {
+ logger.error("Failed to deserialize message: {}", e.getMessage());
+ return ServerResponse.badRequest().bodyValue(new McpError("Invalid message format"));
+ }
+ });
+ }
+
+ /**
+ * Represents an active client session.
+ */
+ private static class ClientSession {
+
+ private final String id;
+
+ private final Sinks.Many> messageSink;
+
+ ClientSession(String id) {
+ this.id = id;
+ logger.debug("Creating new session: {}", id);
+ this.messageSink = Sinks.many().replay().latest();
+ logger.debug("Session {} initialized with replay sink", id);
+ }
+
+ void close() {
+ logger.debug("Closing session: {}", id);
+ Sinks.EmitResult result = messageSink.tryEmitComplete();
+ if (result.isFailure()) {
+ logger.warn("Failed to complete message sink for session {}: {}", id, result);
+ }
+ else {
+ logger.debug("Successfully completed message sink for session {}", id);
+ }
+ }
+
+ }
+
+}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
new file mode 100644
index 0000000..ed92e8d
--- /dev/null
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
@@ -0,0 +1,273 @@
+/*
+ * 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.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.function.Function;
+
+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.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+import reactor.core.scheduler.Scheduler;
+import reactor.core.scheduler.Schedulers;
+
+import org.springframework.ai.mcp.spec.McpSchema;
+import org.springframework.ai.mcp.spec.McpSchema.JSONRPCMessage;
+import org.springframework.ai.mcp.spec.McpTransport;
+import org.springframework.ai.mcp.util.Assert;
+
+/**
+ * Implementation of the MCP Stdio transport for servers that communicates 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
+ */
+public class StdioServerTransport implements McpTransport {
+
+ private static final Logger logger = LoggerFactory.getLogger(StdioServerTransport.class);
+
+ private final Sinks.Many inboundSink;
+
+ private final Sinks.Many outboundSink;
+
+ private ObjectMapper objectMapper;
+
+ /** Scheduler for handling inbound messages */
+ private Scheduler inboundScheduler;
+
+ /** Scheduler for handling outbound messages */
+ private Scheduler outboundScheduler;
+
+ private volatile boolean isClosing = false;
+
+ private final InputStream inputStream;
+
+ private final OutputStream outputStream;
+
+ private final Sinks.One inboundReady = Sinks.one();
+
+ private final Sinks.One outboundReady = Sinks.one();
+
+ /**
+ * Creates a new StdioServerTransport with a default ObjectMapper and System streams.
+ */
+ public StdioServerTransport() {
+ this(new ObjectMapper(), System.in, System.out);
+ }
+
+ /**
+ * Creates a new StdioServerTransport with the specified ObjectMapper and System
+ * streams.
+ * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
+ */
+ public StdioServerTransport(ObjectMapper objectMapper) {
+ this(objectMapper, System.in, System.out);
+ }
+
+ /**
+ * Creates a new StdioServerTransport with the specified ObjectMapper and streams.
+ * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
+ * @param inputStream The input stream to read from
+ * @param outputStream The output stream to write to
+ */
+ public StdioServerTransport(ObjectMapper objectMapper, InputStream inputStream, OutputStream outputStream) {
+ Assert.notNull(objectMapper, "The ObjectMapper can not be null");
+ Assert.notNull(inputStream, "The InputStream can not be null");
+ Assert.notNull(outputStream, "The OutputStream can not be null");
+
+ this.inboundSink = Sinks.many().unicast().onBackpressureBuffer();
+ this.outboundSink = Sinks.many().unicast().onBackpressureBuffer();
+
+ this.objectMapper = objectMapper;
+ this.inputStream = inputStream;
+ this.outputStream = outputStream;
+
+ // Use bounded schedulers for better resource management
+ this.inboundScheduler = Schedulers.newBoundedElastic(1, 1, "inbound");
+ this.outboundScheduler = Schedulers.newBoundedElastic(1, 1, "outbound");
+ }
+
+ @Override
+ public Mono connect(Function, Mono> handler) {
+ return Mono.fromRunnable(() -> {
+ handleIncomingMessages(handler);
+
+ // Start threads
+ startInboundProcessing();
+ startOutboundProcessing();
+ }).subscribeOn(Schedulers.boundedElastic());
+ }
+
+ private void handleIncomingMessages(Function, Mono> inboundMessageHandler) {
+ this.inboundSink.asFlux()
+ .flatMap(message -> Mono.just(message)
+ .transform(inboundMessageHandler)
+ .contextWrite(ctx -> ctx.put("observation", "myObservation")))
+ .subscribe();
+ }
+
+ @Override
+ public Mono sendMessage(JSONRPCMessage message) {
+ return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> {
+ if (this.outboundSink.tryEmitNext(message).isSuccess()) {
+ return Mono.empty();
+ }
+ else {
+ return Mono.error(new RuntimeException("Failed to enqueue message"));
+ }
+ }));
+ }
+
+ /**
+ * Starts the inbound processing thread that reads JSON-RPC messages from stdin.
+ * Messages are deserialized and emitted to the inbound sink.
+ */
+ private void startInboundProcessing() {
+ this.inboundScheduler.schedule(() -> {
+ inboundReady.tryEmitValue(null);
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+ String line;
+ while (!isClosing && (line = reader.readLine()) != null) {
+ try {
+ JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, line);
+ if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
+ if (!isClosing) {
+ logger.error("Failed to enqueue message");
+ }
+ break;
+ }
+ }
+ catch (Exception e) {
+ if (!isClosing) {
+ logger.error("Error processing inbound message", e);
+ }
+ break;
+ }
+ }
+ }
+ catch (IOException e) {
+ if (!isClosing) {
+ logger.error("Error reading from stdin", e);
+ }
+ }
+ finally {
+ if (!isClosing) {
+ isClosing = true;
+ }
+ inboundSink.tryEmitComplete();
+ }
+ });
+ }
+
+ /**
+ * Starts the outbound processing thread that writes JSON-RPC messages to stdout.
+ * Messages are serialized to JSON and written with a newline delimiter.
+ */
+ private void startOutboundProcessing() {
+ Function, Flux> outboundConsumer = messages -> messages // @formatter:off
+ .doOnSubscribe(subscription -> outboundReady.tryEmitValue(null))
+ .publishOn(outboundScheduler)
+ .handle((message, sink) -> {
+ if (message != null && !isClosing) {
+ try {
+ String jsonMessage = objectMapper.writeValueAsString(message);
+ // Escape any embedded newlines in the JSON message as per spec
+ jsonMessage = jsonMessage.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n");
+
+ synchronized (outputStream) {
+ outputStream.write(jsonMessage.getBytes(StandardCharsets.UTF_8));
+ outputStream.write("\n".getBytes(StandardCharsets.UTF_8));
+ outputStream.flush();
+ }
+ sink.next(message);
+ }
+ catch (IOException e) {
+ if (!isClosing) {
+ logger.error("Error writing message", e);
+ sink.error(new RuntimeException(e));
+ }
+ }
+ }
+ else if (isClosing) {
+ sink.complete();
+ }
+ })
+ .doOnComplete(() -> {
+ if (!isClosing) {
+ isClosing = true;
+ }
+ outboundSink.tryEmitComplete();
+ })
+ .doOnError(e -> {
+ if (!isClosing) {
+ logger.error("Error in outbound processing", e);
+ isClosing = true;
+ outboundSink.tryEmitComplete();
+ }
+ })
+ .map(msg -> (JSONRPCMessage) msg);
+
+ outboundConsumer.apply(outboundSink.asFlux()).subscribe();
+ } // @formatter:on
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.fromRunnable(() -> {
+ isClosing = true;
+ logger.debug("Initiating graceful shutdown");
+ // }).then(Mono.delay(Duration.ofMillis(100))).then(Mono.fromRunnable(() -> {
+ }).then(Mono.fromRunnable(() -> {
+ try {
+ // inboundSink.tryEmitComplete();
+ // outboundSink.tryEmitComplete();
+
+ inboundScheduler.dispose();
+ outboundScheduler.dispose();
+
+ // Wait for schedulers to terminate
+ if (!inboundScheduler.isDisposed()) {
+ inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ }
+ if (!outboundScheduler.isDisposed()) {
+ outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ }
+
+ logger.info("Graceful shutdown completed");
+ }
+ catch (Exception e) {
+ logger.error("Error during graceful shutdown", e);
+ }
+ })).then().subscribeOn(Schedulers.boundedElastic());
+ }
+
+ @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/spec/DefaultMcpSession.java b/mcp/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java
index 71786a6..993b396 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/spec/DefaultMcpSession.java
@@ -133,6 +133,7 @@ public class DefaultMcpSession implements McpSession {
// create child Observation and emit it together with the message to the consumer
this.connection = this.transport.connect(mono -> mono.doOnNext(message -> {
if (message instanceof McpSchema.JSONRPCResponse response) {
+ logger.info("Received Response: {}", response);
var sink = pendingResponses.remove(response.id());
if (sink == null) {
logger.warn("Unexpected response for unkown id {}", response.id());
@@ -142,6 +143,7 @@ public class DefaultMcpSession implements McpSession {
}
}
else if (message instanceof McpSchema.JSONRPCRequest request) {
+ logger.info("Received request: {}", request);
handleIncomingRequest(request).subscribe(response -> transport.sendMessage(response).subscribe(),
error -> {
var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
@@ -151,6 +153,7 @@ public class DefaultMcpSession implements McpSession {
});
}
else if (message instanceof McpSchema.JSONRPCNotification notification) {
+ logger.info("Received notification: {}", notification);
handleIncomingNotification(notification).subscribe(null,
error -> logger.error("Error handling notification: {}", error.getMessage()));
}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
index 86cf60e..7d261c3 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
@@ -191,7 +191,7 @@ public class McpSchema {
/**
* Provides a standardized way for servers to request LLM
- * sampling (“completions” or “generations”) from language
+ * sampling ("completions" or "generations") from language
* models via clients. This flow allows clients to maintain
* control over model access, selection, and permissions
* while enabling servers to leverage AI capabilities—with
@@ -202,6 +202,35 @@ public class McpSchema {
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record Sampling() {
}
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private Map experimental;
+ private RootCapabilities roots;
+ private Sampling sampling;
+
+ public Builder experimental(Map experimental) {
+ this.experimental = experimental;
+ return this;
+ }
+
+ public Builder roots(Boolean listChanged) {
+ this.roots = new RootCapabilities(listChanged);
+ return this;
+ }
+
+ public Builder sampling() {
+ this.sampling = new Sampling();
+ return this;
+ }
+
+ public ClientCapabilities build() {
+ return new ClientCapabilities(experimental, roots, sampling);
+ }
+ }
}// @formatter:on
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@@ -227,6 +256,48 @@ public class McpSchema {
public record ToolCapabilities(
@JsonProperty("listChanged") Boolean listChanged) {
}
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+
+ private Map experimental;
+ private Object logging;
+ private PromptCapabilities prompts;
+ private ResourceCapabilities resources;
+ private ToolCapabilities tools;
+
+ public Builder experimental(Map experimental) {
+ this.experimental = experimental;
+ return this;
+ }
+
+ public Builder logging(Object logging) {
+ this.logging = logging;
+ return this;
+ }
+
+ public Builder prompts(Boolean listChanged) {
+ this.prompts = new PromptCapabilities(listChanged);
+ return this;
+ }
+
+ public Builder resources(Boolean subscribe, Boolean listChanged) {
+ this.resources = new ResourceCapabilities(subscribe, listChanged);
+ return this;
+ }
+
+ public Builder tools(Boolean listChanged) {
+ this.tools = new ToolCapabilities(listChanged);
+ return this;
+ }
+
+ public ServerCapabilities build() {
+ return new ServerCapabilities(experimental, logging, prompts, resources, tools);
+ }
+ }
} // @formatter:on
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@@ -304,6 +375,20 @@ public class McpSchema {
@JsonProperty("annotations") Annotations annotations) implements Annotated {
} // @formatter:on
+ /**
+ * Resource templates allow servers to expose parameterized resources using URI
+ * templates.
+ *
+ * @param uriTemplate A URI template that can be used to generate URIs for this
+ * resource.
+ * @param name A human-readable name for this resource. This can be used by clients to
+ * populate UI elements.
+ * @param description A description of what this resource represents. This can be used
+ * by clients to improve the LLM's understanding of available resources. It can be
+ * thought of like a "hint" to the model.
+ * @param mimeType The MIME type of this resource, if known.
+ * @see RFC 6570
+ */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record ResourceTemplate( // @formatter:off
@JsonProperty("uriTemplate") String uriTemplate,
@@ -500,8 +585,22 @@ public class McpSchema {
@JsonProperty("name") String name,
@JsonProperty("description") String description,
@JsonProperty("inputSchema") Map inputSchema) {
+
+ public Tool(String name, String description, String schema) {
+ this(name, description, parseSchema(schema));
+ }
+
} // @formatter:on
+ public static Map parseSchema(String schema) {
+ try {
+ return new ObjectMapper().readValue(schema, MAP_TYPE_REF);
+ }
+ catch (IOException e) {
+ throw new IllegalArgumentException("Invalid schema: " + schema, e);
+ }
+ }
+
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record CallToolRequest(// @formatter:off
@JsonProperty("name") String name,
@@ -543,15 +642,18 @@ public class McpSchema {
}// @formatter:on
@JsonInclude(JsonInclude.Include.NON_ABSENT)
- public record CreateMessageResult(Role role, Content content, String model, StopReason stopReason) {
+ public record CreateMessageResult(// @formatter:off
+ @JsonProperty("role") Role role,
+ @JsonProperty("content") Content content,
+ @JsonProperty("model") String model,
+ @JsonProperty("stopReason") StopReason stopReason) {
+
public enum StopReason {
-
- // @formatter:off
@JsonProperty("end_turn") END_TURN,
@JsonProperty("stop_sequence") STOP_SEQUENCE,
@JsonProperty("max_tokens") MAX_TOKENS
- } // @formatter:on
- }
+ }
+ }// @formatter:on
// ---------------------------
// Model Preferences
@@ -652,6 +754,10 @@ public class McpSchema {
public String type() {
return type;
}
+
+ public TextContent(String content) {
+ this(null, null, "text", content);
+ }
}
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@@ -716,4 +822,4 @@ public class McpSchema {
@JsonProperty("roots") List roots) {
} // @formatter:on
-}
\ No newline at end of file
+}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests2.java b/mcp/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests2.java
index 9ab8205..47e6e73 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests2.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests2.java
@@ -20,8 +20,8 @@ import java.time.Duration;
import org.springframework.ai.mcp.client.McpClient;
import org.springframework.ai.mcp.client.McpSyncClient;
-import org.springframework.ai.mcp.client.stdio.ServerParameters;
-import org.springframework.ai.mcp.client.stdio.StdioClientTransport;
+import org.springframework.ai.mcp.client.transport.ServerParameters;
+import org.springframework.ai.mcp.client.transport.StdioClientTransport;
import org.springframework.ai.mcp.spec.McpSchema.ListResourcesResult;
import org.springframework.ai.mcp.spec.McpSchema.ListToolsResult;
import org.springframework.ai.mcp.spec.McpSchema.Resource;
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests3.java b/mcp/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests3.java
index 474a961..f147aa5 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests3.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/attic/ClientSessionTests3.java
@@ -20,8 +20,8 @@ import java.time.Duration;
import org.springframework.ai.mcp.client.McpClient;
import org.springframework.ai.mcp.client.McpSyncClient;
-import org.springframework.ai.mcp.client.stdio.ServerParameters;
-import org.springframework.ai.mcp.client.stdio.StdioClientTransport;
+import org.springframework.ai.mcp.client.transport.ServerParameters;
+import org.springframework.ai.mcp.client.transport.StdioClientTransport;
import org.springframework.ai.mcp.spec.McpSchema.ListResourcesResult;
import org.springframework.ai.mcp.spec.McpSchema.ListToolsResult;
import org.springframework.ai.mcp.spec.McpSchema.Resource;
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
new file mode 100644
index 0000000..d663f14
--- /dev/null
+++ b/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java
@@ -0,0 +1,61 @@
+/*
+* 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.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import reactor.netty.http.server.HttpServer;
+
+import org.springframework.ai.mcp.server.McpServer;
+import org.springframework.ai.mcp.server.transport.SseServerTransport;
+import org.springframework.ai.mcp.spec.McpSchema;
+import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
+import org.springframework.http.server.reactive.HttpHandler;
+import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+
+/**
+ *
+ * https://docs.spring.io/spring-framework/reference/web/webflux-functional.html#webflux-fn-running
+ * https://docs.spring.io/spring-framework/reference/web/webflux/reactive-spring.html#webflux-httphandler
+ *
+ * @author Christian Tzolov
+ */
+
+public class DemoServer {
+
+ public static void main(String[] args) {
+ SseServerTransport transport = new SseServerTransport(new ObjectMapper(), "/mcp/message");
+
+ var mcpServer = McpServer.using(transport)
+ .serverInfo("Weather Forecast", "1.0.0")
+ .tool(new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")),
+ (arguments) -> {
+ String city = (String) arguments.get("city");
+ return new CallToolResult(List.of(), false);
+ })
+ .async();
+
+ HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction());
+ ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
+ HttpServer httpServer = HttpServer.create().port(8080).handle(adapter);
+ httpServer.bindNow().onDispose().block();
+
+ }
+
+}
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
index e7a6f8d..e4c3004 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/attic/MainSSE.java
@@ -20,7 +20,7 @@ import java.util.Map;
import org.springframework.ai.mcp.client.McpClient;
import org.springframework.ai.mcp.client.McpSyncClient;
-import org.springframework.ai.mcp.client.sse.SseClientTransport;
+import org.springframework.ai.mcp.client.transport.SseClientTransport;
import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
index ac2bb53..72c683a 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
@@ -17,9 +17,8 @@
package org.springframework.ai.mcp.client;
import java.time.Duration;
-import java.util.List;
import java.util.Map;
-import java.util.function.Supplier;
+import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -27,11 +26,14 @@ import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
+import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
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;
@@ -57,9 +59,11 @@ public abstract class AbstractMcpAsyncClientTests {
abstract protected McpTransport createMcpTransport();
- abstract protected void onStart();
+ protected void onStart() {
+ }
- abstract protected void onClose();
+ protected void onClose() {
+ }
@BeforeEach
void setUp() {
@@ -67,7 +71,10 @@ public abstract class AbstractMcpAsyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
- mcpAsyncClient = McpClient.using(mcpTransport).requestTimeout(TIMEOUT).async();
+ mcpAsyncClient = McpClient.using(mcpTransport)
+ .requestTimeout(TIMEOUT)
+ .capabilities(ClientCapabilities.builder().roots(true).build())
+ .async();
mcpAsyncClient.initialize().block(Duration.ofSeconds(10));
}).doesNotThrowAnyException();
}
@@ -175,19 +182,142 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testRootsListChanged() {
- assertThatCode(() -> mcpAsyncClient.sendRootsListChanged().block()).doesNotThrowAnyException();
+ assertThatCode(() -> mcpAsyncClient.rootsListChangedNotification().block()).doesNotThrowAnyException();
}
@Test
void testInitializeWithRootsListProviders() {
var transport = createMcpTransport();
- List>> providers = List.of(() -> List.of(new Root("file:///test/path", "test-root")));
- var client = McpClient.using(transport).requestTimeout(TIMEOUT).rootsListProvider(providers.get(0)).async();
+ 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
+ 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).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();
+
+ var client = McpClient.using(transport).requestTimeout(TIMEOUT).capabilities(capabilities).async();
+
+ assertThatCode(() -> {
+ var result = client.initialize().block(Duration.ofSeconds(10));
+ assertThat(result).isNotNull();
+ assertThat(result.capabilities()).isNotNull();
+ client.closeGracefully().block(Duration.ofSeconds(10));
+ }).doesNotThrowAnyException();
+ }
+
}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java
index eedeb00..c55d775 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpSyncClientTests.java
@@ -18,6 +18,7 @@ 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;
@@ -25,11 +26,17 @@ import org.junit.jupiter.api.Test;
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 org.springframework.ai.mcp.spec.McpTransport;
import static org.assertj.core.api.Assertions.assertThat;
@@ -46,7 +53,7 @@ public abstract class AbstractMcpSyncClientTests {
private McpSyncClient mcpSyncClient;
- private static final Duration TIMEOUT = Duration.ofSeconds(20);
+ private static final Duration TIMEOUT = Duration.ofSeconds(10);
private static final String TEST_MESSAGE = "Hello MCP Spring AI!";
@@ -64,7 +71,10 @@ public abstract class AbstractMcpSyncClientTests {
this.mcpTransport = createMcpTransport();
assertThatCode(() -> {
- mcpSyncClient = McpClient.using(mcpTransport).requestTimeout(TIMEOUT).sync();
+ mcpSyncClient = McpClient.using(mcpTransport)
+ .requestTimeout(TIMEOUT)
+ .capabilities(ClientCapabilities.builder().roots(true).build())
+ .sync();
mcpSyncClient.initialize();
}).doesNotThrowAnyException();
}
@@ -142,7 +152,7 @@ public abstract class AbstractMcpSyncClientTests {
@Test
void testRootsListChanged() {
- assertThatCode(() -> mcpSyncClient.sendRootsListChanged()).doesNotThrowAnyException();
+ assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException();
}
@Test
@@ -165,4 +175,106 @@ public abstract class AbstractMcpSyncClientTests {
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();
+ }
+
}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
index e172f92..c818a47 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
@@ -23,11 +23,8 @@ import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
-import java.util.function.Supplier;
import com.fasterxml.jackson.core.type.TypeReference;
-
-import static org.awaitility.Awaitility.await;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
@@ -36,12 +33,14 @@ import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
import org.springframework.ai.mcp.spec.McpSchema;
+import org.springframework.ai.mcp.spec.McpSchema.ClientCapabilities;
import org.springframework.ai.mcp.spec.McpSchema.JSONRPCNotification;
import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest;
import org.springframework.ai.mcp.spec.McpSchema.Root;
import org.springframework.ai.mcp.spec.McpTransport;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
class McpAsyncClientResponseHandlerTests {
@@ -150,9 +149,9 @@ class McpAsyncClientResponseHandlerTests {
void testRootsListRequestHandling() {
MockMcpTransport transport = new MockMcpTransport();
- Supplier> rootsListProvider = () -> List.of(new Root("file:///test/path", "test-root"));
-
- McpAsyncClient asyncMcpClient = McpClient.using(transport).rootsListProvider(rootsListProvider).async();
+ McpAsyncClient asyncMcpClient = McpClient.using(transport)
+ .roots(new Root("file:///test/path", "test-root"))
+ .async();
// Simulate incoming request
McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, "roots/list",
@@ -263,4 +262,121 @@ class McpAsyncClientResponseHandlerTests {
asyncMcpClient.closeGracefully();
}
+ @Test
+ void testSamplingCreateMessageRequestHandling() {
+ MockMcpTransport transport = new MockMcpTransport();
+
+ // Create a test sampling handler that echoes back the input
+ Function samplingHandler = request -> {
+ var content = request.messages().get(0).content();
+ return new McpSchema.CreateMessageResult(McpSchema.Role.ASSISTANT, content, "test-model",
+ McpSchema.CreateMessageResult.StopReason.END_TURN);
+ };
+
+ // Create client with sampling capability and handler
+ McpAsyncClient asyncMcpClient = McpClient.using(transport)
+ .capabilities(ClientCapabilities.builder().sampling().build())
+ .sampling(samplingHandler)
+ .async();
+
+ // Create a mock create message request
+ var messageRequest = new McpSchema.CreateMessageRequest(
+ List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))),
+ null, // modelPreferences
+ "Test system prompt", McpSchema.CreateMessageRequest.ContextInclusionStrategy.NONE, 0.7, // temperature
+ 100, // maxTokens
+ null, // stopSequences
+ null // metadata
+ );
+
+ // Simulate incoming request
+ McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
+ "sampling/createMessage", "test-id", messageRequest);
+ transport.simulateIncomingMessage(request);
+
+ // Verify response
+ McpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessage();
+ assertThat(sentMessage).isInstanceOf(McpSchema.JSONRPCResponse.class);
+
+ McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage;
+ assertThat(response.id()).isEqualTo("test-id");
+ assertThat(response.error()).isNull();
+
+ McpSchema.CreateMessageResult result = transport.unmarshalFrom(response.result(),
+ new TypeReference() {
+ });
+ assertThat(result).isNotNull();
+ assertThat(result.role()).isEqualTo(McpSchema.Role.ASSISTANT);
+ assertThat(result.content()).isNotNull();
+ assertThat(result.model()).isEqualTo("test-model");
+ assertThat(result.stopReason()).isEqualTo(McpSchema.CreateMessageResult.StopReason.END_TURN);
+
+ asyncMcpClient.closeGracefully();
+ }
+
+ @Test
+ void testSamplingCreateMessageRequestHandlingWithoutCapability() {
+ MockMcpTransport transport = new MockMcpTransport();
+
+ // Create client without sampling capability
+ McpAsyncClient asyncMcpClient = McpClient.using(transport)
+ .capabilities(ClientCapabilities.builder().build()) // No sampling capability
+ .async();
+
+ // Create a mock create message request
+ var messageRequest = new McpSchema.CreateMessageRequest(
+ List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))),
+ null, null, null, null, 0, null, null);
+
+ // Simulate incoming request
+ McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
+ "sampling/createMessage", "test-id", messageRequest);
+ transport.simulateIncomingMessage(request);
+
+ // Verify error response
+ McpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessage();
+ assertThat(sentMessage).isInstanceOf(McpSchema.JSONRPCResponse.class);
+
+ McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage;
+ assertThat(response.id()).isEqualTo("test-id");
+ assertThat(response.result()).isNull();
+ assertThat(response.error()).isNotNull();
+ assertThat(response.error().message()).contains("Method not found: sampling/createMessage");
+
+ asyncMcpClient.closeGracefully();
+ }
+
+ @Test
+ void testSamplingCreateMessageRequestHandlingWithNullHandler() {
+ MockMcpTransport transport = new MockMcpTransport();
+
+ // Create client with sampling capability but null handler
+ McpAsyncClient asyncMcpClient = McpClient.using(transport)
+ .capabilities(ClientCapabilities.builder().sampling().build())
+ .sampling(null) // Null handler
+ .async();
+
+ // Create a mock create message request
+ var messageRequest = new McpSchema.CreateMessageRequest(
+ List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Test message"))),
+ null, null, null, null, 0, null, null);
+
+ // Simulate incoming request
+ McpSchema.JSONRPCRequest request = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION,
+ "sampling/createMessage", "test-id", messageRequest);
+ transport.simulateIncomingMessage(request);
+
+ // Verify error response
+ McpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessage();
+ assertThat(sentMessage).isInstanceOf(McpSchema.JSONRPCResponse.class);
+
+ McpSchema.JSONRPCResponse response = (McpSchema.JSONRPCResponse) sentMessage;
+ assertThat(response.id()).isEqualTo("test-id");
+ assertThat(response.result()).isNull();
+ assertThat(response.error()).isNotNull();
+ assertThat(response.error().message()).contains("Method not found: sampling/createMessage");
+
+ asyncMcpClient.closeGracefully();
+ }
+
}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/stdio/McpAsyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientTests.java
similarity index 85%
rename from mcp/src/test/java/org/springframework/ai/mcp/client/stdio/McpAsyncClientTests.java
rename to mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientTests.java
index 64b3307..9c5db68 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/stdio/McpAsyncClientTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientTests.java
@@ -14,11 +14,12 @@
* limitations under the License.
*/
-package org.springframework.ai.mcp.client.stdio;
+package org.springframework.ai.mcp.client;
import org.junit.jupiter.api.Timeout;
-import org.springframework.ai.mcp.client.AbstractMcpAsyncClientTests;
+import org.springframework.ai.mcp.client.transport.ServerParameters;
+import org.springframework.ai.mcp.client.transport.StdioClientTransport;
import org.springframework.ai.mcp.spec.McpTransport;
/**
@@ -38,14 +39,4 @@ class McpAsyncClientTests extends AbstractMcpAsyncClientTests {
return new StdioClientTransport(stdioParams);
}
- @Override
- protected void onStart() {
-
- }
-
- @Override
- protected void onClose() {
-
- }
-
}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/stdio/McpSyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/McpSyncClientTests.java
similarity index 86%
rename from mcp/src/test/java/org/springframework/ai/mcp/client/stdio/McpSyncClientTests.java
rename to mcp/src/test/java/org/springframework/ai/mcp/client/McpSyncClientTests.java
index beff095..d23c39c 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/stdio/McpSyncClientTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/McpSyncClientTests.java
@@ -14,15 +14,15 @@
* limitations under the License.
*/
-package org.springframework.ai.mcp.client.stdio;
+package org.springframework.ai.mcp.client;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
-import org.springframework.ai.mcp.client.AbstractMcpSyncClientTests;
-import org.springframework.ai.mcp.client.McpSyncClient;
+import org.springframework.ai.mcp.client.transport.ServerParameters;
+import org.springframework.ai.mcp.client.transport.StdioClientTransport;
import org.springframework.ai.mcp.spec.McpTransport;
import static org.assertj.core.api.Assertions.assertThat;
@@ -48,7 +48,8 @@ class McpSyncClientTests extends AbstractMcpSyncClientTests {
@Test
void customErrorHandlerShouldReceiveErrors() {
AtomicReference receivedError = new AtomicReference<>();
- ((StdioClientTransport) mcpTransport).errorHandler = receivedError::set;
+
+ ((StdioClientTransport) mcpTransport).setErrorHandler(error -> receivedError.set(error));
String errorMessage = "Test error";
((StdioClientTransport) mcpTransport).getErrorSink().tryEmitNext(errorMessage);
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpAsyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpAsyncClientTests.java
similarity index 91%
rename from mcp/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpAsyncClientTests.java
rename to mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpAsyncClientTests.java
index 4faa70d..84657a6 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpAsyncClientTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpAsyncClientTests.java
@@ -14,14 +14,13 @@
* limitations under the License.
*/
-package org.springframework.ai.mcp.client.sse;
+package org.springframework.ai.mcp.client;
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.ai.mcp.client.transport.SseClientTransport;
import org.springframework.ai.mcp.spec.McpTransport;
import org.springframework.web.reactive.function.client.WebClient;
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpSyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpSyncClientTests.java
similarity index 91%
rename from mcp/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpSyncClientTests.java
rename to mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpSyncClientTests.java
index 65e81e0..55632c3 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/sse/SseMcpSyncClientTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/SseMcpSyncClientTests.java
@@ -14,14 +14,13 @@
* limitations under the License.
*/
-package org.springframework.ai.mcp.client.sse;
+package org.springframework.ai.mcp.client;
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.ai.mcp.client.transport.SseClientTransport;
import org.springframework.ai.mcp.spec.McpTransport;
import org.springframework.web.reactive.function.client.WebClient;
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/sse/SseClientTransportTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/transport/SseClientTransportTests.java
similarity index 99%
rename from mcp/src/test/java/org/springframework/ai/mcp/client/sse/SseClientTransportTests.java
rename to mcp/src/test/java/org/springframework/ai/mcp/client/transport/SseClientTransportTests.java
index 6e2e378..2ab13ff 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/sse/SseClientTransportTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/transport/SseClientTransportTests.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package org.springframework.ai.mcp.client.sse;
+package org.springframework.ai.mcp.client.transport;
import java.time.Duration;
import java.util.Map;
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
new file mode 100644
index 0000000..4f17973
--- /dev/null
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpAsyncServerTests.java
@@ -0,0 +1,342 @@
+/*
+ * 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.spec.McpSchema;
+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.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 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 AbstractMcpAsyncServerTests {
+
+ 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 McpTransport createMcpTransport();
+
+ protected void onStart() {
+ }
+
+ protected void onClose() {
+ }
+
+ @BeforeEach
+ void setUp() {
+ }
+
+ @AfterEach
+ void tearDown() {
+ onClose();
+ }
+
+ @Test
+ void testConstructorWithInvalidArguments() {
+ assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Transport must not be null");
+
+ assertThatThrownBy(() -> McpServer.using(createMcpTransport()).info((McpSchema.Implementation) null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Server info must not be null");
+ }
+
+ @Test
+ void testAddTool() {
+ Tool newTool = new McpSchema.Tool("new-tool", "New test tool", Map.of("input", "string"));
+ 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", Map.of("input", "string"));
+
+ 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", Map.of("input", "string"));
+
+ 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", Map.of("input", "string"));
+
+ 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)));
+ }
+
+ @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 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 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();
+ }
+
+ @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 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 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 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 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");
+ });
+ }
+
+ @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();
+ }
+
+}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
new file mode 100644
index 0000000..fb268ba
--- /dev/null
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
@@ -0,0 +1,314 @@
+/*
+ * 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.spec.McpSchema;
+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.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 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 AbstractMcpSyncServerTests {
+
+ 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 McpTransport createMcpTransport();
+
+ protected void onStart() {
+ }
+
+ protected void onClose() {
+ }
+
+ @BeforeEach
+ void setUp() {
+ }
+
+ @AfterEach
+ void tearDown() {
+ onClose();
+ }
+
+ @Test
+ void testConstructorWithInvalidArguments() {
+ assertThatThrownBy(() -> McpServer.using(null)).isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Transport must not be null");
+
+ assertThatThrownBy(() -> McpServer.using(createMcpTransport()).info((McpSchema.Implementation) null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Server info must not be null");
+ }
+
+ @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", Map.of("input", "string"));
+ 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", Map.of("input", "string"));
+
+ 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", Map.of("input", "string"));
+
+ 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();
+ }
+
+ @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 testNotifyPromptsListChanged() {
+ var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+
+ assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+ @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 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 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 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 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 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");
+ }
+
+ @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.addPrompt(registration)).doesNotThrowAnyException();
+
+ 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();
+ }
+
+ @Test
+ void testGetAsyncServer() {
+ var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+
+ assertThat(mcpSyncServer.getAsyncServer()).isNotNull();
+
+ assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
+ }
+
+}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java
new file mode 100644
index 0000000..b9a2b77
--- /dev/null
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java
@@ -0,0 +1,64 @@
+/*
+ * 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.springframework.http.server.reactive.HttpHandler;
+import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+import reactor.netty.DisposableServer;
+import reactor.netty.http.server.HttpServer;
+
+import org.springframework.ai.mcp.server.transport.SseServerTransport;
+import org.springframework.ai.mcp.spec.McpTransport;
+
+/**
+ * Tests for {@link McpAsyncServer} using {@link SseServerTransport}.
+ *
+ * @author Christian Tzolov
+ */
+class SseMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
+
+ private static final int PORT = 8181;
+
+ private static final String MESSAGE_ENDPOINT = "/mcp/message";
+
+ private DisposableServer httpServer;
+
+ @Override
+ protected McpTransport createMcpTransport() {
+ var transport = new SseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
+
+ HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction());
+ ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
+ httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
+
+ return transport;
+ }
+
+ @Override
+ protected void onStart() {
+ }
+
+ @Override
+ protected void onClose() {
+ if (httpServer != null) {
+ httpServer.disposeNow();
+ }
+ }
+
+}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java
new file mode 100644
index 0000000..2d92574
--- /dev/null
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java
@@ -0,0 +1,64 @@
+/*
+ * 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.springframework.http.server.reactive.HttpHandler;
+import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
+import org.springframework.web.reactive.function.server.RouterFunctions;
+import reactor.netty.DisposableServer;
+import reactor.netty.http.server.HttpServer;
+
+import org.springframework.ai.mcp.server.transport.SseServerTransport;
+import org.springframework.ai.mcp.spec.McpTransport;
+
+/**
+ * Tests for {@link McpSyncServer} using {@link SseServerTransport}.
+ *
+ * @author Christian Tzolov
+ */
+class SseMcpSyncServerTests extends AbstractMcpSyncServerTests {
+
+ private static final int PORT = 8182;
+
+ private static final String MESSAGE_ENDPOINT = "/mcp/message";
+
+ private DisposableServer httpServer;
+
+ private SseServerTransport transport;
+
+ @Override
+ protected McpTransport createMcpTransport() {
+ transport = new SseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT);
+ return transport;
+ }
+
+ @Override
+ protected void onStart() {
+ HttpHandler httpHandler = RouterFunctions.toHttpHandler(transport.getRouterFunction());
+ ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
+ httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
+ }
+
+ @Override
+ protected void onClose() {
+ if (httpServer != null) {
+ httpServer.disposeNow();
+ }
+ }
+
+}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java
new file mode 100644
index 0000000..9a0185c
--- /dev/null
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java
@@ -0,0 +1,36 @@
+/*
+ * 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.springframework.ai.mcp.server.transport.StdioServerTransport;
+import org.springframework.ai.mcp.spec.McpTransport;
+
+/**
+ * Tests for {@link McpAsyncServer} using {@link StdioServerTransport}.
+ *
+ * @author Christian Tzolov
+ */
+class StdioMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
+
+ @Override
+ protected McpTransport createMcpTransport() {
+ return new StdioServerTransport(new ObjectMapper());
+ }
+
+}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java
new file mode 100644
index 0000000..745264a
--- /dev/null
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java
@@ -0,0 +1,83 @@
+/*
+ * 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.io.PipedInputStream;
+import java.io.PipedOutputStream;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.springframework.ai.mcp.server.transport.StdioServerTransport;
+import org.springframework.ai.mcp.spec.McpTransport;
+
+/**
+ * Tests for {@link McpSyncServer} using {@link StdioServerTransport}.
+ *
+ * @author Christian Tzolov
+ */
+class StdioMcpSyncServerTests extends AbstractMcpSyncServerTests {
+
+ private PipedInputStream testInput;
+
+ private PipedOutputStream testOutput;
+
+ private PipedOutputStream writeToInput;
+
+ private PipedInputStream readFromOutput;
+
+ @Override
+ protected McpTransport createMcpTransport() {
+ try {
+ testInput = new PipedInputStream();
+ writeToInput = new PipedOutputStream(testInput);
+ testOutput = new PipedOutputStream();
+ readFromOutput = new PipedInputStream(testOutput);
+ return new StdioServerTransport(new ObjectMapper(), testInput, testOutput);
+ }
+ catch (Exception e) {
+ throw new RuntimeException("Failed to initialize test streams", e);
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ // No special setup needed for stdio transport
+ }
+
+ @Override
+ protected void onClose() {
+ try {
+ if (testInput != null) {
+ testInput.close();
+ }
+ if (testOutput != null) {
+ testOutput.close();
+ }
+ if (writeToInput != null) {
+ writeToInput.close();
+ }
+ if (readFromOutput != null) {
+ readFromOutput.close();
+ }
+ }
+ catch (Exception e) {
+ // Log but don't throw since this is cleanup
+ e.printStackTrace();
+ }
+ }
+
+}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/transport/BlockingInputStream.java b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/BlockingInputStream.java
new file mode 100644
index 0000000..96690db
--- /dev/null
+++ b/mcp/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/src/test/java/org/springframework/ai/mcp/server/transport/SseServerTransportTests.java
new file mode 100644
index 0000000..80f31a2
--- /dev/null
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/SseServerTransportTests.java
@@ -0,0 +1,283 @@
+package org.springframework.ai.mcp.server.transport;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.Disposable;
+import reactor.core.publisher.Flux;
+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.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.codec.ServerSentEvent;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for the {@link SseServerTransport} class.
+ *
+ * @author Christian Tzolov
+ */
+@Timeout(15)
+class SseServerTransportTests {
+
+ private static final Logger logger = LoggerFactory.getLogger(SseServerTransportTests.class);
+
+ private ObjectMapper objectMapper;
+
+ private String messageEndpoint;
+
+ private SseServerTransport transport;
+
+ private WebTestClient webTestClient;
+
+ @BeforeEach
+ void setUp() {
+ objectMapper = new ObjectMapper();
+ messageEndpoint = "/message";
+ transport = new SseServerTransport(objectMapper, messageEndpoint);
+ webTestClient = WebTestClient.bindToRouterFunction(transport.getRouterFunction()).build();
+ }
+
+ @Test
+ void constructorValidation() {
+ assertThatThrownBy(() -> new SseServerTransport(null, "/message")).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("ObjectMapper must not be null");
+
+ assertThatThrownBy(() -> new SseServerTransport(new ObjectMapper(), null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Message endpoint must not be null");
+ }
+
+ @Test
+ void testSseConnectionEstablishment() {
+ List> events = new ArrayList<>();
+
+ webTestClient.get()
+ .uri("/sse")
+ .accept(MediaType.TEXT_EVENT_STREAM)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .expectHeader()
+ .contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)
+ .returnResult(String.class)
+ .getResponseBody()
+ .map(data -> ServerSentEvent.builder().data(data).build())
+ .take(1) // Take only the initial endpoint event
+ .subscribe(events::add);
+
+ // Wait a bit for the event to be received
+ StepVerifier.create(Mono.delay(Duration.ofMillis(500))).expectNextCount(1).verifyComplete();
+
+ assertThat(events).hasSize(1);
+ assertThat(events.get(0).data()).isEqualTo(messageEndpoint);
+ }
+
+ @Test
+ void testMessageHandling() {
+ AtomicReference receivedMessage = new AtomicReference<>();
+
+ // Set up message handler
+ transport.connect(message -> {
+ message.doOnNext(receivedMessage::set).subscribe();
+ return Mono.empty();
+ }).block();
+
+ // Create a test message
+ JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id",
+ Map.of("key", "value"));
+
+ // Send message to endpoint
+ webTestClient.post()
+ .uri(messageEndpoint)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue(testMessage)
+ .exchange()
+ .expectStatus()
+ .isOk();
+
+ // Verify the message was received and processed
+ assertThat(receivedMessage.get()).isNotNull();
+ McpSchema.JSONRPCRequest receivedRequest = (McpSchema.JSONRPCRequest) receivedMessage.get();
+ assertThat(receivedRequest.id()).isEqualTo(testMessage.id());
+ assertThat(receivedRequest.method()).isEqualTo(testMessage.method());
+ }
+
+ @Test
+ @Disabled("Flaky test")
+ void testBroadcastMessage() {
+ // Create test clients
+ int clientCount = 3;
+ CountDownLatch connectLatch = new CountDownLatch(clientCount);
+ CountDownLatch messageLatch = new CountDownLatch(clientCount);
+
+ List>> allReceivedEvents = new ArrayList<>();
+ List subscriptions = new ArrayList<>();
+
+ // Connect clients
+ for (int i = 0; i < clientCount; i++) {
+ List> clientEvents = new ArrayList<>();
+ allReceivedEvents.add(clientEvents);
+
+ Flux> eventStream = webTestClient.get()
+ .uri("/sse")
+ .accept(MediaType.TEXT_EVENT_STREAM)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .returnResult(String.class)
+ .getResponseBody()
+ .map(data -> ServerSentEvent.builder().data(data).build());
+
+ Disposable subscription = eventStream.doOnNext(event -> {
+ clientEvents.add(event);
+ if (event.event() != null && event.event().equals(SseServerTransport.ENDPOINT_EVENT_TYPE)) {
+ connectLatch.countDown();
+ }
+ else if (event.event() != null && event.event().equals(SseServerTransport.MESSAGE_EVENT_TYPE)) {
+ messageLatch.countDown();
+ }
+ }).subscribe();
+
+ subscriptions.add(subscription);
+ }
+
+ // Wait for all clients to connect
+ try {
+ assertThat(connectLatch.await(5, TimeUnit.SECONDS)).isTrue();
+ }
+ catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+
+ // Verify initial connections
+ for (List> events : allReceivedEvents) {
+ assertThat(events).hasSize(1);
+ assertThat(events.get(0).data()).isEqualTo(messageEndpoint);
+ }
+
+ // Give clients time to fully establish their subscriptions
+ logger.debug("Waiting for subscriptions to stabilize...");
+ try {
+ Thread.sleep(1000);
+ }
+ catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ logger.debug("Sending broadcast message to {} clients", clientCount);
+
+ // Send broadcast message
+ JSONRPCRequest broadcastMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "broadcast", "broadcast-id",
+ Map.of("message", "Hello all!"));
+
+ // Send the message
+ transport.sendMessage(broadcastMessage).block(Duration.ofSeconds(5));
+
+ // Wait for all clients to receive the broadcast
+ try {
+ assertThat(messageLatch.await(5, TimeUnit.SECONDS)).isTrue();
+ }
+ catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+
+ // Verify each client received both messages
+ for (List> events : allReceivedEvents) {
+ assertThat(events).hasSize(2);
+ assertThat(events.get(0).data()).isEqualTo(messageEndpoint);
+ assertThat(events.get(1).data()).contains("broadcast-id");
+ }
+
+ // Cleanup
+ subscriptions.forEach(Disposable::dispose);
+ }
+
+ @Test
+ void testGracefulShutdown() {
+ // Connect a client
+ Flux> eventStream = webTestClient.get()
+ .uri("/sse")
+ .accept(MediaType.TEXT_EVENT_STREAM)
+ .exchange()
+ .expectStatus()
+ .isOk()
+ .returnResult(String.class)
+ .getResponseBody()
+ .map(data -> ServerSentEvent.builder().data(data).build());
+
+ List> receivedEvents = new ArrayList<>();
+ eventStream.subscribe(receivedEvents::add);
+
+ // Wait for connection
+ StepVerifier.create(Mono.delay(Duration.ofMillis(500))).expectNextCount(1).verifyComplete();
+
+ // Initiate shutdown
+ transport.closeGracefully().block(Duration.ofSeconds(5));
+
+ // Verify server rejects new connections with timeout
+ webTestClient.get()
+ .uri("/sse")
+ .accept(MediaType.TEXT_EVENT_STREAM)
+ .exchange()
+ .expectStatus()
+ .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
+ .expectBody(String.class)
+ .isEqualTo("Server is shutting down");
+
+ // Verify server rejects new messages with timeout
+ webTestClient.post()
+ .uri(messageEndpoint)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue("""
+ {
+ "jsonrpc": "2.0",
+ "method": "test",
+ "id": "1",
+ "params": {}
+ }
+ """)
+ .exchange()
+ .expectStatus()
+ .isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
+ .expectBody(String.class)
+ .isEqualTo("Server is shutting down");
+ }
+
+ @Test
+ void testInvalidMessageHandling() {
+ // Test invalid JSON
+ webTestClient.post()
+ .uri(messageEndpoint)
+ .contentType(MediaType.APPLICATION_JSON)
+ .bodyValue("invalid json")
+ .exchange()
+ .expectStatus()
+ .isBadRequest();
+
+ // Test invalid message format
+ webTestClient.post().uri(messageEndpoint).contentType(MediaType.APPLICATION_JSON).bodyValue("""
+ {
+ "invalid": "message"
+ }
+ """).exchange().expectStatus().isBadRequest();
+ }
+
+}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java
new file mode 100644
index 0000000..34e549b
--- /dev/null
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java
@@ -0,0 +1,174 @@
+/*
+ * 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.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import org.springframework.ai.mcp.spec.McpSchema;
+import org.springframework.ai.mcp.spec.McpSchema.JSONRPCRequest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link StdioServerTransport}.
+ *
+ * @author Christian Tzolov
+ */
+class StdioServerTransportTests {
+
+ private final InputStream originalIn = System.in;
+
+ private final PrintStream originalOut = System.out;
+
+ private final PrintStream originalErr = System.err;
+
+ private ByteArrayOutputStream testOut;
+
+ private ByteArrayInputStream testIn;
+
+ private ByteArrayOutputStream testErr;
+
+ private PrintStream testOutPrintStream;
+
+ private StdioServerTransport transport;
+
+ private ObjectMapper objectMapper;
+
+ @BeforeEach
+ void setUp() {
+ testOut = new ByteArrayOutputStream();
+ testErr = new ByteArrayOutputStream();
+ testOutPrintStream = new PrintStream(testOut, true);
+ System.setOut(testOutPrintStream);
+ System.setErr(new PrintStream(testErr));
+
+ objectMapper = new ObjectMapper();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (transport != null) {
+ transport.closeGracefully().block();
+ }
+ if (testOutPrintStream != null) {
+ testOutPrintStream.close();
+ }
+ System.setIn(originalIn);
+ System.setOut(originalOut);
+ System.setErr(originalErr);
+ }
+
+ @Test
+ void shouldHandleIncomingMessages() throws Exception {
+ // Prepare test input
+ String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{},\"id\":1}";
+ testIn = new ByteArrayInputStream((jsonMessage + "\n").getBytes(StandardCharsets.UTF_8));
+
+ // Create transport with test streams
+ transport = new StdioServerTransport(objectMapper, testIn, testOutPrintStream);
+
+ // Parse expected message
+ McpSchema.JSONRPCRequest expected = objectMapper.readValue(jsonMessage, McpSchema.JSONRPCRequest.class);
+
+ // Connect transport with message handler and verify message
+ StepVerifier.create(transport.connect(message -> message.doOnNext(msg -> {
+ McpSchema.JSONRPCRequest received = (McpSchema.JSONRPCRequest) msg;
+ assertThat(received.id()).isEqualTo(expected.id());
+ assertThat(received.method()).isEqualTo(expected.method());
+ }))).verifyComplete();
+ }
+
+ @Test
+ @Disabled
+ void shouldHandleOutgoingMessages() throws Exception {
+ // Create transport with test streams
+ transport = new StdioServerTransport(objectMapper, new BlockingInputStream(), testOutPrintStream);
+ // transport = new StdioServerTransport(objectMapper, new ByteArrayInputStream(new
+ // byte[0]), testOutPrintStream);
+
+ // Create test messages
+ JSONRPCRequest initMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "init", "init-id",
+ Map.of("init", "true"));
+ JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test", "test-id",
+ Map.of("key", "value"));
+
+ // Connect transport, send messages, and verify output in a reactive chain
+ StepVerifier.create(transport.connect(message -> message)
+ .then(transport.sendMessage(initMessage))
+ // .then(Mono.fromRunnable(() -> testOut.reset())) // Clear buffer after init
+ // message
+ .then(transport.sendMessage(testMessage))
+ .then(Mono.fromCallable(() -> {
+ String output = testOut.toString(StandardCharsets.UTF_8);
+ assertThat(output).contains("\"jsonrpc\":\"2.0\"");
+ assertThat(output).contains("\"method\":\"test\"");
+ assertThat(output).contains("\"id\":\"test-id\"");
+ return null;
+ }))).verifyComplete();
+ }
+
+ @Test
+ void shouldWaitForProcessorsBeforeSendingMessage() {
+ // Create transport with test streams
+ transport = new StdioServerTransport(objectMapper, new ByteArrayInputStream(new byte[0]), testOutPrintStream);
+
+ // Create test message
+ JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test", "test-id",
+ Map.of("key", "value"));
+
+ // Try to send message before connecting (before processors are ready)
+ StepVerifier.create(transport.sendMessage(testMessage)).verifyTimeout(java.time.Duration.ofMillis(100));
+
+ // Connect transport and verify message can be sent
+ StepVerifier.create(transport.connect(message -> message).then(transport.sendMessage(testMessage)))
+ .verifyComplete();
+ }
+
+ @Test
+ void shouldCloseGracefully() {
+ // Create transport with test streams
+ transport = new StdioServerTransport(objectMapper, new ByteArrayInputStream(new byte[0]), testOutPrintStream);
+
+ // Create test message
+ JSONRPCRequest initMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "init", "init-id",
+ Map.of("init", "true"));
+
+ // Connect transport, send message, and close gracefully in a reactive chain
+ StepVerifier
+ .create(transport.connect(message -> message)
+ .then(transport.sendMessage(initMessage))
+ .then(transport.closeGracefully()))
+ .verifyComplete();
+
+ // Verify error log is empty
+ assertThat(testErr.toString()).doesNotContain("Error");
+ }
+
+}
diff --git a/pom.xml b/pom.xml
index 94e7b94..3c1151f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -98,6 +98,7 @@
mcp
spring-ai-mcp
+ spring-ai-mcp-sample
diff --git a/spring-ai-mcp-sample/README.md b/spring-ai-mcp-sample/README.md
new file mode 100644
index 0000000..4c53b48
--- /dev/null
+++ b/spring-ai-mcp-sample/README.md
@@ -0,0 +1,257 @@
+# Spring AI MCP Sample Project
+
+This sample project demonstrates the usage of the Spring AI Model Context Protocol (MCP) implementation. It showcases how to create and use MCP servers and clients with different transport modes and capabilities.
+
+## Overview
+
+The sample provides:
+- Two transport mode implementations: Stdio and SSE (Server-Sent Events)
+- Server capabilities:
+ - Tools support with list changes notifications
+ - Resources support with list changes notifications (no subscribe support)
+ - Prompts support with list changes notifications
+- Sample implementations:
+ - Two MCP tools: Weather and Calculator
+ - One MCP resource: System Information
+ - One MCP prompt: Greeting
+
+## Building the Project
+
+```bash
+./mvnw clean package
+```
+
+## Running the Server
+
+The server can be started in two transport modes, controlled by the `transport.mode` property:
+
+### Stdio Mode (Default)
+
+```bash
+java -Dtransport.mode=stdio -jar target/spring-ai-mcp-sample-0.3.0-SNAPSHOT.jar
+```
+
+The Stdio mode server is automatically started by the client - no explicit server startup is needed.
+But you have to build the server jar first: `./mvnw clean install -DskipTests`.
+
+In Stdio mode the server must not emit any messages/logs to the console (e.g. standard out) but the JSON messages produced by the server.
+
+### SSE Mode
+```bash
+java -Dtransport.mode=sse -jar target/spring-ai-mcp-sample-0.3.0-SNAPSHOT.jar
+```
+
+## Sample Clients
+
+The project includes example clients for both transport modes:
+
+### Stdio Client (ClientStdio.java)
+```java
+var stdioParams = ServerParameters.builder("java")
+ .args("-Dtransport.mode=stdio", "-jar",
+ "target/spring-ai-mcp-sample-0.3.0-SNAPSHOT.jar")
+ .build();
+
+var transport = new StdioClientTransport(stdioParams);
+var client = McpClient.using(transport).sync();
+```
+
+### SSE Client (ClientSse.java)
+```java
+var transport = new SseClientTransport(WebClient.builder().baseUrl("http://localhost:8080"));
+var client = McpClient.using(transport).sync();
+```
+
+## Available Tools
+
+### Weather Tool
+- Name: `weather`
+- Description: Weather forecast tool by location
+- Parameters:
+ - `city`: String - The city to get weather for
+- Example:
+```java
+CallToolResult response = client.callTool(
+ new CallToolRequest("weather", Map.of("city", "Sofia"))
+);
+```
+
+### Calculator Tool
+- Name: `calculator`
+- Description: Performs basic arithmetic operations
+- Parameters (JSON Schema):
+ ```json
+ {
+ "operation": {
+ "type": "string",
+ "enum": ["add", "subtract", "multiply", "divide"],
+ "description": "The arithmetic operation to perform"
+ },
+ "a": {
+ "type": "number",
+ "description": "First operand"
+ },
+ "b": {
+ "type": "number",
+ "description": "Second operand"
+ }
+ }
+ ```
+- Example:
+```java
+CallToolResult response = client.callTool(
+ new CallToolRequest("calculator",
+ Map.of("operation", "multiply", "a", 2.0, "b", 3.0))
+);
+```
+
+## Available Resources
+
+### System Information Resource
+- URI: `system://info`
+- Description: Provides basic system information including Java version, OS, etc.
+- MIME Type: application/json
+- Returns: JSON object containing: javaVersion, osName, osVersion, osArch, processors, timestamp
+
+## Available Prompts
+
+### Greeting Prompt
+- Name: `greeting`
+- Description: A friendly greeting prompt
+- Parameters:
+ - `name`: String (required) - The name to greet
+- Returns: A personalized greeting message from an assistant
+
+## Client Usage Example
+
+```java
+// Initialize client
+client.initialize();
+
+// Test connection
+client.ping();
+
+// List available tools
+ListToolsResult tools = client.listTools();
+System.out.println("Available tools: " + tools);
+
+// Call weather tool
+CallToolResult weather = client.callTool(
+ new CallToolRequest("weather", Map.of("city", "Sofia"))
+);
+System.out.println("Weather: " + weather);
+
+// Call calculator tool
+CallToolResult calc = client.callTool(
+ new CallToolRequest("calculator",
+ Map.of("operation", "multiply", "a", 2.0, "b", 3.0))
+);
+System.out.println("Calculation: " + calc);
+
+// Access system info resource
+ReadResourceResult sysInfo = client.readResource(
+ new ReadResourceRequest("system://info")
+);
+System.out.println("System Info: " + sysInfo);
+
+// Use greeting prompt
+GetPromptResult greeting = client.getPrompt(
+ new GetPromptRequest("greeting", Map.of("name", "John"))
+);
+System.out.println("Greeting: " + greeting);
+
+// Close client
+client.closeGracefully();
+```
+
+## Server Usage Example
+
+```java
+@Configuration
+public class CustomMcpServerConfig {
+ @Bean
+ public McpAsyncServer mcpServer(McpTransport transport) {
+ // Configure server capabilities
+ var capabilities = McpSchema.ServerCapabilities.builder()
+ .resources(false, true) // Resource support with list changes notifications
+ .tools(true) // Tool support with list changes notifications
+ .prompts(true) // Prompt support with list changes notifications
+ .build();
+
+ // Create custom tool
+ var customTool = new ToolRegistration(
+ new McpSchema.Tool("custom-tool", "Description", Map.of("param", "String")),
+ (arguments) -> {
+ String param = (String) arguments.get("param");
+ return new CallToolResult(
+ List.of(new TextContent("Result: " + param)),
+ false
+ );
+ }
+ );
+
+ // Create custom resource
+ var customResource = new ResourceRegistration(
+ new McpSchema.Resource(
+ "custom://resource",
+ "Custom Resource",
+ "Description",
+ "application/json",
+ null
+ ),
+ (request) -> new ReadResourceResult(
+ List.of(new TextResourceContents(
+ request.uri(),
+ "application/json",
+ "{\"data\": \"example\"}"
+ ))
+ )
+ );
+
+ // Create custom prompt
+ var customPrompt = new PromptRegistration(
+ new McpSchema.Prompt(
+ "custom-prompt",
+ "Description",
+ List.of(new McpSchema.PromptArgument("input", "Description", true))
+ ),
+ request -> {
+ String input = (String) request.arguments().get("input");
+ var message = new PromptMessage(
+ Role.ASSISTANT,
+ new TextContent("Response to: " + input)
+ );
+ return new GetPromptResult("Result", List.of(message));
+ }
+ );
+
+ // Create and configure the server
+ return McpServer.using(transport)
+ .info("Custom MCP Server", "1.0.0")
+ .capabilities(capabilities)
+ .tools(List.of(customTool))
+ .resources(Map.of("custom://resource", customResource))
+ .prompts(List.of(customPrompt))
+ .async();
+ }
+}
+```
+
+## Configuration
+
+The application can be configured through `application.properties`:
+
+- `transport.mode`: Transport mode to use (stdio/sse)
+- `server.port`: Server port for SSE mode (default: 8080)
+- Various logging configurations are available for debugging
+
+## Implementation Details
+
+The sample demonstrates:
+- Creating an MCP server with custom tools, resources, and prompts
+- Configuring different transport modes
+- Implementing tool handlers with JSON schema validation
+- Resource implementations with dynamic content generation
+- Prompt implementations with parameter handling
+- Error handling and response formatting
+- Synchronous client usage patterns
diff --git a/spring-ai-mcp-sample/pom.xml b/spring-ai-mcp-sample/pom.xml
new file mode 100644
index 0000000..85199ea
--- /dev/null
+++ b/spring-ai-mcp-sample/pom.xml
@@ -0,0 +1,71 @@
+
+
+ 4.0.0
+
+ org.springframework.experimental
+ mcp-parent
+ 0.3.0-SNAPSHOT
+
+
+ spring-ai-mcp-sample
+ Spring AI MCP Sample
+ Sample Spring Boot application demonstrating MCP server usage
+
+
+ 3.4.1
+
+
+
+
+ org.springframework.experimental
+ mcp
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+ ${spring-boot.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ ${spring-boot.version}
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+ ${spring-boot.version}
+
+
+
+ repackage
+
+
+
+
+
+ io.spring.javaformat
+ spring-javaformat-maven-plugin
+ 0.0.43
+
+
+ validate
+ true
+
+ validate
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java
new file mode 100644
index 0000000..50b47e3
--- /dev/null
+++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientSse.java
@@ -0,0 +1,33 @@
+/*
+* 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.SseClientTransport;
+import org.springframework.web.reactive.function.client.WebClient;
+
+/**
+ * @author Christian Tzolov
+ */
+
+public class ClientSse {
+
+ public static void main(String[] args) {
+ var transport = new SseClientTransport(WebClient.builder().baseUrl("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/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java
new file mode 100644
index 0000000..2aeb1d7
--- /dev/null
+++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java
@@ -0,0 +1,39 @@
+/*
+* 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.ServerParameters;
+import org.springframework.ai.mcp.client.transport.StdioClientTransport;
+
+/**
+ * @author Christian Tzolov
+ */
+
+public class ClientStdio {
+
+ public static void main(String[] args) {
+
+ var stdioParams = ServerParameters.builder("java")
+ .args("-Dtransport.mode=stdio", "-jar",
+ "spring-ai-mcp-sample/target/spring-ai-mcp-sample-0.3.0-SNAPSHOT.jar")
+ .build();
+
+ var transport = new StdioClientTransport(stdioParams);
+
+ new SampleClient(transport).run();
+ }
+
+}
diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java
new file mode 100644
index 0000000..69904b6
--- /dev/null
+++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/SampleClient.java
@@ -0,0 +1,80 @@
+/*
+* 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 java.util.Map;
+
+import org.springframework.ai.mcp.client.McpClient;
+import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
+import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
+import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
+import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
+import org.springframework.ai.mcp.spec.McpSchema.ListToolsResult;
+import org.springframework.ai.mcp.spec.McpSchema.ReadResourceRequest;
+import org.springframework.ai.mcp.spec.McpTransport;
+
+/**
+ * @author Christian Tzolov
+ */
+
+public class SampleClient {
+
+ private final McpTransport transport;
+
+ public SampleClient(McpTransport transport) {
+ this.transport = transport;
+ }
+
+ public void run() {
+
+ var client = McpClient.using(this.transport).sync();
+
+ client.initialize();
+
+ client.ping();
+
+ // List and demonstrate tools
+ ListToolsResult toolsList = client.listTools();
+ System.out.println("Available Tools = " + toolsList);
+
+ CallToolResult weatherResponse = client.callTool(new CallToolRequest("weather", Map.of("city", "Sofia")));
+ System.out.println("Weather Response = " + weatherResponse);
+
+ CallToolResult calcResponse = client
+ .callTool(new CallToolRequest("calculator", Map.of("operation", "multiply", "a", 2.0, "b", 3.0)));
+ System.out.println("Calculator Response = " + calcResponse);
+
+ // List and demonstrate resources
+ var resourcesList = client.listResources();
+ System.out.println("\nAvailable Resources = " + resourcesList);
+
+ // Read the system info resource
+ var systemInfo = client.readResource(new ReadResourceRequest("system://info"));
+ System.out.println("System Info = " + systemInfo);
+
+ // List and demonstrate prompts
+ ListPromptsResult promptsList = client.listPrompts();
+ System.out.println("\nAvailable Prompts = " + promptsList);
+
+ // Try the greeting prompt
+ var greetingResponse = client.getPrompt(new GetPromptRequest("greeting", Map.of("name", "Spring")));
+ System.out.println("Greeting Response = " + greetingResponse);
+
+ client.closeGracefully();
+
+ }
+
+}
diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java
new file mode 100644
index 0000000..586f8d8
--- /dev/null
+++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java
@@ -0,0 +1,13 @@
+package org.springframework.ai.mcp.sample.server;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class McpServerApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(McpServerApplication.class, args);
+ }
+
+}
diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java
new file mode 100644
index 0000000..968b122
--- /dev/null
+++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/server/McpServerConfig.java
@@ -0,0 +1,187 @@
+package org.springframework.ai.mcp.sample.server;
+
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.springframework.ai.mcp.server.McpAsyncServer;
+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.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.PromptMessage;
+import org.springframework.ai.mcp.spec.McpSchema.Role;
+import org.springframework.ai.mcp.spec.McpSchema.TextContent;
+import org.springframework.ai.mcp.spec.McpTransport;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.reactive.function.server.RouterFunction;
+
+@Configuration
+public class McpServerConfig {
+
+ @Bean
+ @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "stdio")
+ public StdioServerTransport stdioServerTransport() {
+ return new StdioServerTransport();
+ }
+
+ @Bean
+ @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse")
+ public SseServerTransport sseServerTransport() {
+ return new SseServerTransport(new ObjectMapper(), "/mcp/message");
+ }
+
+ @Bean
+ @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse")
+ public RouterFunction> mcpRouterFunction(SseServerTransport transport) {
+ return transport.getRouterFunction();
+ }
+
+ @Bean
+ public McpAsyncServer mcpServer(McpTransport transport) { // @formatter:off
+
+ // Configure server capabilities with resource support
+ var capabilities = McpSchema.ServerCapabilities.builder()
+ .resources(false, true) // No subscribe support, but list changes notifications
+ .tools(true) // Tool support with list changes notifications
+ .prompts(true) // Prompt support with list changes notifications
+ .build();
+
+ // Create the server with both tool and resource capabilities
+ return McpServer.using(transport)
+ .info("MCP Demo Server", "1.0.0")
+ .capabilities(capabilities)
+ .tools(List.of(weatherTool(), calculatorTool()))
+ .resources(Map.of("system://info", systemInfoResource()))
+ .prompts(List.of(greetingPrompt()))
+ .async();
+ } // @formatter:on
+
+ private static ResourceRegistration systemInfoResource() {
+
+ // Create a resource registration for system information
+ var systemInfoResource = new McpSchema.Resource( // @formatter:off
+ "system://info",
+ "System Information",
+ "Provides basic system information including Java version, OS, etc.",
+ "application/json", null
+ );
+
+ var resourceRegistration = new ResourceRegistration(systemInfoResource, (request) -> {
+ try {
+ var systemInfo = Map.of(
+ "javaVersion", System.getProperty("java.version"),
+ "osName", System.getProperty("os.name"),
+ "osVersion", System.getProperty("os.version"),
+ "osArch", System.getProperty("os.arch"),
+ "processors", Runtime.getRuntime().availableProcessors(),
+ "timestamp", System.currentTimeMillis());
+
+ String jsonContent = new ObjectMapper().writeValueAsString(systemInfo);
+
+ return new McpSchema.ReadResourceResult(
+ List.of(new McpSchema.TextResourceContents(request.uri(), "application/json", jsonContent)));
+ }
+ catch (Exception e) {
+ throw new RuntimeException("Failed to generate system info", e);
+ }
+ }); // @formatter:on
+
+ return resourceRegistration;
+ }
+
+ private static ToolRegistration weatherTool() {
+ return new ToolRegistration(
+ new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")),
+ (arguments) -> {
+ String city = (String) arguments.get("city");
+ TextContent content = new TextContent("Weather forecast for " + city + " is sunny");
+ return new CallToolResult(List.of(content), false);
+ });
+ }
+
+ private static ToolRegistration calculatorTool() {
+ return new ToolRegistration(new McpSchema.Tool("calculator",
+ "Performs basic arithmetic operations (add, subtract, multiply, divide)", """
+ {
+ "type": "object",
+ "properties": {
+ "operation": {
+ "type": "string",
+ "enum": ["add", "subtract", "multiply", "divide"],
+ "description": "The arithmetic operation to perform"
+ },
+ "a": {
+ "type": "number",
+ "description": "First operand"
+ },
+ "b": {
+ "type": "number",
+ "description": "Second operand"
+ }
+ },
+ "required": ["operation", "a", "b"]
+ }
+ """), arguments -> {
+ String operation = (String) arguments.get("operation");
+ double a = (Double) arguments.get("a");
+ double b = (Double) arguments.get("b");
+
+ double result;
+ switch (operation) {
+ case "add":
+ result = a + b;
+ break;
+ case "subtract":
+ result = a - b;
+ break;
+ case "multiply":
+ result = a * b;
+ break;
+ case "divide":
+ if (b == 0) {
+ return new McpSchema.CallToolResult(
+ java.util.List.of(new McpSchema.TextContent("Division by zero")), true);
+ }
+ result = a / b;
+ break;
+ default:
+ return new McpSchema.CallToolResult(
+ java.util.List.of(new McpSchema.TextContent("Unknown operation: " + operation)),
+ true);
+ }
+
+ return new McpSchema.CallToolResult(
+ java.util.List.of(new McpSchema.TextContent(String.valueOf(result))), false);
+ });
+ }
+
+ private static PromptRegistration greetingPrompt() {
+ var nameArg = new McpSchema.PromptArgument("name", "The name to greet", true // required
+ // parameter
+ );
+
+ var prompt = new McpSchema.Prompt("greeting", "A friendly greeting prompt", List.of(nameArg));
+
+ return new PromptRegistration(prompt, request -> {
+ String name = (String) request.arguments().get("name");
+ if (name == null) {
+ name = "friend";
+ }
+
+ var assistantMessage = new PromptMessage(Role.ASSISTANT,
+ new TextContent("Hello " + name + "! How can I assist you today?"));
+
+ return new GetPromptResult("A personalized greeting message", List.of(assistantMessage));
+ });
+ }
+
+}
diff --git a/spring-ai-mcp-sample/src/main/resources/application.properties b/spring-ai-mcp-sample/src/main/resources/application.properties
new file mode 100644
index 0000000..8682ce9
--- /dev/null
+++ b/spring-ai-mcp-sample/src/main/resources/application.properties
@@ -0,0 +1,16 @@
+# Server port
+# server.port=8080
+
+# Logging
+# logging.level.org.springframework.ai.mcp=DEBUG
+# logging.level.org.springframework.web=INFO
+
+spring.main.banner-mode=off
+# logging.level.root=OFF
+# spring.main.web-application-type=none
+# logging.pattern.console=
+logging.level.org.springframework.ai.mcp.*=OFF
+logging.level.org.springframework.ai.mcp.spec.*=OFF
+logging.level.org.springframework.**=OFF
+
+transport.mode=sse
\ No newline at end of file
diff --git a/spring-ai-mcp/README.md b/spring-ai-mcp/README.md
index 4b3733c..29ade4c 100644
--- a/spring-ai-mcp/README.md
+++ b/spring-ai-mcp/README.md
@@ -31,7 +31,7 @@ To use this module, add the following dependency to your Maven project:
org.springframework.experimental
spring-ai-mcp
- 0.2.0-SNAPSHOT
+ 0.3.0-SNAPSHOT
```
From 347f86ff19b29c02539010da22e3c6b9fc09884b Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sat, 28 Dec 2024 23:56:16 +0100
Subject: [PATCH 004/514] docs Improve MCP readme
---
mcp/README.md | 336 +++++++++++-------
.../ai/mcp/server/McpServer.java | 13 -
.../ai/mcp/server/McpSyncServer.java | 42 +--
.../ai/mcp/attic/DemoServer.java | 2 +-
.../server/AbstractMcpAsyncServerTests.java | 36 +-
.../server/AbstractMcpSyncServerTests.java | 38 +-
6 files changed, 267 insertions(+), 200 deletions(-)
diff --git a/mcp/README.md b/mcp/README.md
index be0a690..7851f21 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -1,10 +1,6 @@
-# Model Context Protocol (MCP) Java SDK
+# Spring AI MCP SDK
-A Java implementation of the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture) specification, providing both synchronous and asynchronous clients for MCP server interactions.
-
-## Overview
-
-This SDK implements the Model Context Protocol, enabling seamless integration with AI models and tools through a standardized interface. It supports both synchronous and asynchronous communication patterns, making it suitable for various use cases and integration scenarios.
+This SDK implements the Model Context Protocol, enabling seamless integration with language models and AI tools.
## Features
@@ -46,11 +42,6 @@ The SDK follows a layered architecture with clear separation of concerns:
- **Session Layer (McpSession)**: Manages communication patterns and state using DefaultMcpSession implementation.
- **Client/Server Layer**: Both use McpSession for sync/async operations, with McpClient handling client-side protocol operations and McpServer managing server-side protocol operations.
-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.
-
-
-
-
### Key Interactions
- **Client/Server Initialization**: Transport setup, protocol compatibility check, capability negotiation, and implementation details exchange.
@@ -59,149 +50,183 @@ Following class diagram illustrates the layered architecture of the MCP SDK, sho
- **Prompt System**: Discovery, parameter-based retrieval, change notifications, and content management.
- **Tool Execution**: Discovery, parameter validation, timeout-aware execution, and result processing.
+## Client Usage Examples
-### [Reference Documentation](../docs/ref-index.md)
-Detailed reference documentation covering the Spring AI implementation of the Model Context Protocol (MCP) specification.
-
-
-## Quick Start
-
-### McpClient
-
-Factory for creating `sync` and `async` variants MCP clients. Uses McpSession for communication and handles client-side protocol operations.
-
-#### Sync MCP Client
+### MCP Client (Sync API)
```java
-// Create and initialize sync client
-McpTransport mcpTransport = ...
+// Create a sync client with custom configuration
+McpSyncClient client = McpClient.using(transport)
+ .requestTimeout(Duration.ofSeconds(10))
+ .capabilities(ClientCapabilities.builder()
+ .roots(true) // Enable roots capability
+ .sampling() // Enable sampling capability
+ .build())
+ .sync();
-try (McpSyncClient client = McpClient.using(mcpTransport).sync()) {
- // Initialize connection with protocol version and capabilities
- InitializeResult initResult = client.initialize();
+// Initialize connection
+client.initialize();
- // List available tools
- ListToolsResult tools = client.listTools();
+// List available tools
+ListToolsResult tools = client.listTools();
- // Execute a tool
- CallToolResult result = client.callTool(
- new CallToolRequest("echo", Map.of("message", "Hello!"))
- );
+// Call a tool
+CallToolResult result = client.callTool(
+ new CallToolRequest("calculator",
+ Map.of("operation", "add", "a", 2, "b", 3))
+);
- // Resource management
- ListResourcesResult resources = client.listResources();
- ReadResourceResult resource = client.readResource(
- new ReadResourceRequest("resource-uri")
- );
+// List and read resources
+ListResourcesResult resources = client.listResources();
+ReadResourceResult resource = client.readResource(
+ new ReadResourceRequest("resource://uri")
+);
- // List and retrieve prompts
- ListPromptsResult prompts = client.listPrompts();
- GetPromptResult prompt = client.getPrompt(
- new GetPromptRequest("prompt-id", Map.of())
- );
-}
+// List and use prompts
+ListPromptsResult prompts = client.listPrompts();
+GetPromptResult prompt = client.getPrompt(
+ new GetPromptRequest("greeting", Map.of("name", "Spring"))
+);
+
+// Add/remove roots
+client.addRoot(new Root("file:///path", "description"));
+client.removeRoot("file:///path");
+client.rootsListChangedNotification();
+
+// Close client
+client.closeGracefully();
```
-#### Async MCP Client
+### MCP Client (Async API)
```java
-McpTransport mcpTransport = ...
-
-// Initialize async client with custom timeout
-McpAsyncClient client = McpClient.using(mcpTransport)
- .requestTimeout(Duration.ofSeconds(30))
+// Create an async client with custom configuration
+McpAsyncClient client = McpClient.using(transport)
+ .requestTimeout(Duration.ofSeconds(10))
+ .capabilities(ClientCapabilities.builder()
+ .roots(true) // Enable roots capability
+ .sampling() // Enable sampling capability
+ .build())
+ .toolsChangeConsumer(tools -> {
+ logger.info("Tools updated: {}", tools);
+ })
+ .resourcesChangeConsumer(resources -> {
+ logger.info("Resources updated: {}", resources);
+ })
+ .promptsChangeConsumer(prompts -> {
+ logger.info("Prompts updated: {}", prompts);
+ })
.async();
-// Initialize connection and chain operations
-var result = client.initialize()
- // Connection initialized with protocol version compatibility
- .flatMap(initResult -> client.listTools())
- // Process available tools
- .flatMap(tools -> client.callTool(
- new CallToolRequest("echo", Map.of("message", "Hello MCP!"))))
- // Handle tool execution result
- .flatMap(toolResult -> client.listPrompts())
- // Process available prompts
- .flatMap(prompts -> client.getPrompt(new GetPromptRequest("prompt-id", Map.of()));
-
-// Subscribe to resource changes
-var subscription = client.listResources()
- .flatMap(resources -> client.subscribeResource(new SubscribeRequest("resource-uri")));
-
-// Handle results reactively or block if needed
-GetPromptResult promptResult = result.block();
-subscription.block();
-
-// Cleanup
-client.closeGracefully().block();
+// Initialize connection
+client.initialize()
+ .flatMap(initResult -> {
+ // List available tools
+ return client.listTools();
+ })
+ .flatMap(tools -> {
+ // Call a tool
+ return client.callTool(new CallToolRequest(
+ "calculator",
+ Map.of("operation", "add", "a", 2, "b", 3)
+ ));
+ })
+ .flatMap(result -> {
+ // List and read resources
+ return client.listResources()
+ .flatMap(resources ->
+ client.readResource(new ReadResourceRequest("resource://uri"))
+ );
+ })
+ .flatMap(resource -> {
+ // List and use prompts
+ return client.listPrompts()
+ .flatMap(prompts ->
+ client.getPrompt(new GetPromptRequest(
+ "greeting",
+ Map.of("name", "Spring")
+ ))
+ );
+ })
+ .flatMap(prompt -> {
+ // Add/remove roots
+ return client.addRoot(new Root("file:///path", "description"))
+ .then(client.removeRoot("file:///path"))
+ .then(client.rootsListChangedNotification());
+ })
+ .doFinally(signalType -> {
+ // Close client
+ client.closeGracefully().subscribe();
+ })
+ .subscribe();
```
-#### Client Transports
-Clients can use the following transport options:
-
-##### StdioClientTransport
-Standard I/O based transport for process-based communication with MCP servers:
+### Client Transport Options
+#### StdioClientTransport
```java
+// Create transport for process-based communication
ServerParameters params = ServerParameters.builder("npx")
.args("-y", "@modelcontextprotocol/server-everything", "dir")
.build();
McpTransport transport = new StdioClientTransport(params);
```
-##### SseClientTransport
-Server-Sent Events (SSE) based transport following the MCP HTTP with SSE transport specification:
-
+#### SseClientTransport
```java
+// Create transport for HTTP streaming
WebClient.Builder webClientBuilder = WebClient.builder()
.baseUrl("http://your-mcp-server");
McpTransport transport = new SseClientTransport(webClientBuilder);
```
-### MCP Server
+## Server Usage Examples
-Factory for creating `sync` and `async` variants MCP servers. Uses McpSession for communication and handles server-side protocol operations.
-
-#### MCP Sync Server
+### MCP Server (Sync API)
```java
-// Create a sync server with custom configuration
+// Create a server with custom configuration
McpSyncServer syncServer = McpServer.using(transport)
- .serverInfo("my-server", "1.0.0")
- .serverCapabilities(new ServerCapabilities(...))
- .addTool(new CalculatorTool())
- .resourcesProvider(cursor -> resources)
- .promptsProvider(cursor -> prompts)
+ .info("my-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder()...build())
+ .tools(new CalculatorTool())
+ .resources(resourceRegistration)
+ .prompts(promptRegistration)
.sync();
// Add a tool handler at runtime
syncServer.addTool(new CalculatorTool());
-logger.info("Tool added");
-// Remove a tool handler
+// Remove a tool handler at runtime
syncServer.removeTool("calculator");
-logger.info("Tool removed");
-// Notify clients of changes
-syncServer.notifyToolsListChanged();
-syncServer.notifyResourcesListChanged();
-syncServer.notifyPromptsListChanged();
+// Add a resource at runtime
+syncServer.addResource(resourceRegistration);
+
+// Remove a resource at runtime
+syncServer.removeResource(resourceUri);
+
+// Add a prompt at runtime
+syncServer.addPrompt(promptRegistration);
+
+// Remove a prompt at runtime
+syncServer.removePrompt(promptName);
// Graceful shutdown
syncServer.closeGracefully();
```
-#### MCP Async Server
+### MCP Server (Async API)
```java
// Create an async server with custom configuration
McpAsyncServer asyncServer = McpServer.using(transport)
- .serverInfo("my-server", "1.0.0")
- .serverCapabilities(new ServerCapabilities(...))
- .addTool(new CalculatorTool())
- .resourcesProvider(cursor -> resources)
- .promptsProvider(cursor -> prompts)
+ .info("my-server", "1.0.0")
+ .capabilities(ServerCapabilities.builder()...build())
+ .tools(new CalculatorTool())
+ .resources(resourceRegistration)
+ .prompts(promptRegistration)
.async();
// Add a tool handler at runtime
@@ -209,11 +234,31 @@ asyncServer.addTool(new CalculatorTool())
.doOnSuccess(v -> logger.info("Tool added"))
.subscribe();
-// Remove a tool handler
+// Remove a tool handler at runtime
asyncServer.removeTool("calculator")
.doOnSuccess(v -> logger.info("Tool removed"))
.subscribe();
+// Add a resource at runtime
+asyncServer.addResource(resourceRegistration)
+ .doOnSuccess(v -> logger.info("Resource added"))
+ .subscribe();
+
+// Remove a resource at runtime
+asyncServer.removeResource(resourceUri)
+ .doOnSuccess(v -> logger.info("Resource removed"))
+ .subscribe();
+
+// Add a prompt at runtime
+asyncServer.addPrompt(promptRegistration)
+ .doOnSuccess(v -> logger.info("Prompt added"))
+ .subscribe();
+
+// Remove a prompt at runtime
+asyncServer.removePrompt(promptName)
+ .doOnSuccess(v -> logger.info("Prompt removed"))
+ .subscribe();
+
// Notify clients of changes
asyncServer.notifyToolsListChanged().subscribe();
asyncServer.notifyResourcesListChanged().subscribe();
@@ -223,12 +268,9 @@ asyncServer.notifyPromptsListChanged().subscribe();
asyncServer.closeGracefully().subscribe();
```
-#### Server Transports
+### Server Transport Options
-Servers can use the following transport options:
-
-##### StdioServerTransport
-Standard I/O based transport for process-based communication:
+#### StdioServerTransport
```java
// Create transport with custom ObjectMapper
ObjectMapper mapper = new ObjectMapper();
@@ -237,8 +279,7 @@ StdioServerTransport transport = new StdioServerTransport(mapper);
Provides bidirectional JSON-RPC message handling over standard input/output streams with non-blocking message processing, serialization/deserialization, and graceful shutdown support.
-##### SseServerTransport
-Server-Sent Events based transport for HTTP streaming:
+#### SseServerTransport
```java
// Create SSE transport
ObjectMapper mapper = new ObjectMapper();
@@ -251,26 +292,65 @@ RouterFunction> router = 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.
+
+### Server Capabilities
+
+The server can be configured with various capabilities:
+
+```java
+var capabilities = ServerCapabilities.builder()
+ .resources(false, true) // Resource support with list changes notifications
+ .tools(true) // Tool support with list changes notifications
+ .prompts(true) // Prompt support with list changes notifications
+ .build();
+```
+
+### Tool Registration
+
+```java
+var toolRegistration = new ToolRegistration(
+ new Tool("calculator", "Basic calculator", Map.of(
+ "operation", "string",
+ "a", "number",
+ "b", "number"
+ )),
+ arguments -> {
+ // Tool implementation
+ return new CallToolResult(result, false);
+ }
+);
+```
+
+### Resource Registration
+
+```java
+var resourceRegistration = new ResourceRegistration(
+ new Resource("custom://resource", "name", "description", "mime-type", null),
+ request -> {
+ // Resource read implementation
+ return new ReadResourceResult(contents);
+ }
+);
+```
+
+### Prompt Registration
+
+```java
+var promptRegistration = new PromptRegistration(
+ new Prompt("greeting", "description", List.of(
+ new PromptArgument("name", "description", true)
+ )),
+ request -> {
+ // Prompt implementation
+ return new GetPromptResult(description, messages);
+ }
+);
+```
+
## Error Handling
The SDK provides comprehensive error handling through the McpError class, covering protocol compatibility, transport communication, JSON-RPC messaging, tool execution, resource management, prompt handling, timeouts, and connection issues. This unified error handling approach ensures consistent and reliable error management across both synchronous and asynchronous operations.
## Contributing
-Contributions are welcome! Please feel free to submit a Pull Request.
-
-## License
-
-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.
+See [CONTRIBUTING.md](../CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
index 4ab0bde..cbabff4 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
@@ -155,19 +155,6 @@ public interface McpServer {
return this;
}
- /**
- * Set the server implementation information using name and version.
- * @param name The server name
- * @param version The server version
- * @return This builder instance
- */
- public Builder serverInfo(String name, String version) {
- Assert.notNull(name, "Server name must not be null");
- Assert.notNull(version, "Server version must not be null");
- this.serverInfo = new McpSchema.Implementation(name, version);
- return this;
- }
-
/**
* Set the server capabilities.
* @param serverCapabilities The server capabilities configuration
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java
index df13512..2650c9d 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpSyncServer.java
@@ -60,27 +60,6 @@ public class McpSyncServer {
this.asyncServer.removeTool(toolName).block();
}
- /**
- * Notify clients that the list of available tools has changed.
- */
- public void notifyToolsListChanged() {
- this.asyncServer.notifyToolsListChanged().block();
- }
-
- /**
- * Notify clients that the list of available resources has changed.
- */
- public void notifyResourcesListChanged() {
- this.asyncServer.notifyResourcesListChanged().block();
- }
-
- /**
- * Notify clients that the list of available prompts has changed.
- */
- public void notifyPromptsListChanged() {
- this.asyncServer.notifyPromptsListChanged().block();
- }
-
/**
* Add a new resource handler.
* @param resourceHandler The resource handler to add
@@ -113,6 +92,27 @@ public class McpSyncServer {
this.asyncServer.removePrompt(promptName).block();
}
+ /**
+ * Notify clients that the list of available tools has changed.
+ */
+ public void notifyToolsListChanged() {
+ this.asyncServer.notifyToolsListChanged().block();
+ }
+
+ /**
+ * Notify clients that the list of available resources has changed.
+ */
+ public void notifyResourcesListChanged() {
+ this.asyncServer.notifyResourcesListChanged().block();
+ }
+
+ /**
+ * Notify clients that the list of available prompts has changed.
+ */
+ public void notifyPromptsListChanged() {
+ this.asyncServer.notifyPromptsListChanged().block();
+ }
+
/**
* Close the server gracefully.
*/
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
index d663f14..a389b4b 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/attic/DemoServer.java
@@ -43,7 +43,7 @@ public class DemoServer {
SseServerTransport transport = new SseServerTransport(new ObjectMapper(), "/mcp/message");
var mcpServer = McpServer.using(transport)
- .serverInfo("Weather Forecast", "1.0.0")
+ .info("Weather Forecast", "1.0.0")
.tool(new McpSchema.Tool("weather", "Weather forecast tool by location", Map.of("city", "String")),
(arguments) -> {
String city = (String) arguments.get("city");
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 4f17973..3799a39 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
@@ -89,7 +89,7 @@ public abstract class AbstractMcpAsyncServerTests {
void testAddTool() {
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", Map.of("input", "string"));
var mcpAsyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.async();
@@ -105,7 +105,7 @@ public abstract class AbstractMcpAsyncServerTests {
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string"));
var mcpAsyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(duplicateTool, args -> new CallToolResult(List.of(), false))
.async();
@@ -126,7 +126,7 @@ public abstract class AbstractMcpAsyncServerTests {
Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string"));
var mcpAsyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(too, args -> new CallToolResult(List.of(), false))
.async();
@@ -139,7 +139,7 @@ public abstract class AbstractMcpAsyncServerTests {
@Test
void testRemoveNonexistentTool() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.async();
@@ -155,7 +155,7 @@ public abstract class AbstractMcpAsyncServerTests {
Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string"));
var mcpAsyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(too, args -> new CallToolResult(List.of(), false))
.async();
@@ -167,7 +167,7 @@ public abstract class AbstractMcpAsyncServerTests {
@Test
void testNotifyResourcesListChanged() {
- var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async();
+ var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete();
@@ -176,7 +176,7 @@ public abstract class AbstractMcpAsyncServerTests {
@Test
void testNotifyPromptsListChanged() {
- var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async();
+ var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete();
@@ -185,14 +185,14 @@ public abstract class AbstractMcpAsyncServerTests {
@Test
void testGracefulShutdown() {
- var mcpAsyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").async();
+ var mcpAsyncServer = McpServer.using(createMcpTransport()).info("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();
+ var mcpAsyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").async();
assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException();
}
@@ -200,7 +200,7 @@ public abstract class AbstractMcpAsyncServerTests {
@Test
void testAddResource() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().resources(true, false).build())
.async();
@@ -218,7 +218,7 @@ public abstract class AbstractMcpAsyncServerTests {
void testAddResourceWithNullRegistration() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().resources(true, false).build())
.async();
@@ -232,7 +232,7 @@ public abstract class AbstractMcpAsyncServerTests {
@Test
void testAddPromptWithNullRegistration() {
var mcpAsyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(false).build())
.async();
@@ -245,7 +245,7 @@ public abstract class AbstractMcpAsyncServerTests {
void testAddResourceWithoutCapability() {
// Create a server without resource capabilities
McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.async();
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
@@ -263,7 +263,7 @@ public abstract class AbstractMcpAsyncServerTests {
void testAddPromptWithoutCapability() {
// Create a server without prompt capabilities
McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.async();
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", List.of());
@@ -281,7 +281,7 @@ public abstract class AbstractMcpAsyncServerTests {
void testRemoveResourceWithoutCapability() {
// Create a server without resource capabilities
McpAsyncServer serverWithoutResources = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.async();
StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> {
@@ -294,7 +294,7 @@ public abstract class AbstractMcpAsyncServerTests {
void testRemovePromptWithoutCapability() {
// Create a server without prompt capabilities
McpAsyncServer serverWithoutPrompts = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.async();
StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> {
@@ -313,7 +313,7 @@ public abstract class AbstractMcpAsyncServerTests {
List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
var mcpAsyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(true).build())
.prompts(registration)
.async();
@@ -326,7 +326,7 @@ public abstract class AbstractMcpAsyncServerTests {
@Test
void testRemoveNonexistentPrompt() {
var mcpAsyncServer2 = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(true).build())
.async();
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
index fb268ba..15ef810 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
@@ -86,7 +86,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testAddTool() {
var mcpSyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.sync();
@@ -103,7 +103,7 @@ public abstract class AbstractMcpSyncServerTests {
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", Map.of("input", "string"));
var mcpSyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(duplicateTool, args -> new CallToolResult(List.of(), false))
.sync();
@@ -121,7 +121,7 @@ public abstract class AbstractMcpSyncServerTests {
Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", Map.of("input", "string"));
var mcpSyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.tool(tool, args -> new CallToolResult(List.of(), false))
.sync();
@@ -134,7 +134,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testRemoveNonexistentTool() {
var mcpSyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.sync();
@@ -146,7 +146,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testNotifyToolsListChanged() {
- var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+ var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
assertThatCode(() -> mcpSyncServer.notifyToolsListChanged()).doesNotThrowAnyException();
@@ -155,7 +155,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testNotifyResourcesListChanged() {
- var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+ var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
assertThatCode(() -> mcpSyncServer.notifyResourcesListChanged()).doesNotThrowAnyException();
@@ -164,7 +164,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testNotifyPromptsListChanged() {
- var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+ var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException();
@@ -173,14 +173,14 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testGracefulShutdown() {
- var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+ var mcpSyncServer = McpServer.using(createMcpTransport()).info("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();
+ var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException();
}
@@ -188,7 +188,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testAddResource() {
var mcpSyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().resources(true, false).build())
.sync();
@@ -205,7 +205,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testAddResourceWithNullRegistration() {
var mcpSyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().resources(true, false).build())
.sync();
@@ -218,7 +218,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testAddPromptWithNullRegistration() {
var mcpSyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(false).build())
.sync();
@@ -228,7 +228,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testAddResourceWithoutCapability() {
- var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+ var serverWithoutResources = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
null);
@@ -241,7 +241,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testAddPromptWithoutCapability() {
- var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+ var serverWithoutPrompts = McpServer.using(createMcpTransport()).info("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(
@@ -254,7 +254,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testRemoveResourceWithoutCapability() {
- var serverWithoutResources = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+ var serverWithoutResources = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class)
.hasMessage("Server must be configured with resource capabilities");
@@ -262,7 +262,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testRemovePromptWithoutCapability() {
- var serverWithoutPrompts = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+ var serverWithoutPrompts = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).isInstanceOf(McpError.class)
.hasMessage("Server must be configured with prompt capabilities");
@@ -277,7 +277,7 @@ public abstract class AbstractMcpSyncServerTests {
List.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
var mcpSyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(true).build())
.prompts(registration)
.sync();
@@ -292,7 +292,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testRemoveNonexistentPrompt() {
var mcpSyncServer = McpServer.using(createMcpTransport())
- .serverInfo("test-server", "1.0.0")
+ .info("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().prompts(true).build())
.sync();
@@ -304,7 +304,7 @@ public abstract class AbstractMcpSyncServerTests {
@Test
void testGetAsyncServer() {
- var mcpSyncServer = McpServer.using(createMcpTransport()).serverInfo("test-server", "1.0.0").sync();
+ var mcpSyncServer = McpServer.using(createMcpTransport()).info("test-server", "1.0.0").sync();
assertThat(mcpSyncServer.getAsyncServer()).isNotNull();
From bc75b27e536d98ba217ceccb0cb75f664e76e1b1 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sun, 29 Dec 2024 00:25:24 +0100
Subject: [PATCH 005/514] docs(mcp) add class diagram
---
mcp/README.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/mcp/README.md b/mcp/README.md
index 7851f21..a2745d2 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -42,6 +42,10 @@ The SDK follows a layered architecture with clear separation of concerns:
- **Session Layer (McpSession)**: Manages communication patterns and state using DefaultMcpSession implementation.
- **Client/Server Layer**: Both use McpSession for sync/async operations, with McpClient handling client-side protocol operations and McpServer managing server-side protocol operations.
+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.
+
+
+
### Key Interactions
- **Client/Server Initialization**: Transport setup, protocol compatibility check, capability negotiation, and implementation details exchange.
@@ -50,6 +54,7 @@ The SDK follows a layered architecture with clear separation of concerns:
- **Prompt System**: Discovery, parameter-based retrieval, change notifications, and content management.
- **Tool Execution**: Discovery, parameter validation, timeout-aware execution, and result processing.
+
## Client Usage Examples
### MCP Client (Sync API)
From 759fbf13d1d1ba46f59d9b446fadb27f6f633773 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sun, 29 Dec 2024 00:25:49 +0100
Subject: [PATCH 006/514] adjust diagram size
---
mcp/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/mcp/README.md b/mcp/README.md
index a2745d2..11a2ad1 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -44,7 +44,7 @@ The SDK follows a layered architecture with clear separation of concerns:
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.
-
+
### Key Interactions
From 9130e742fd2aae756d9d4ca33c96b23238985543 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sun, 29 Dec 2024 10:24:32 +0100
Subject: [PATCH 007/514] fix(mcp): Improve transport shutdown and error
handling
- Add isClosing flag to coordinate graceful shutdown
- Synchronize output stream writes to prevent race conditions
- Enhance error handling in stream processing
- Add delay for pending message processing during shutdown
- Improve connection state tracking in tests
---
.../transport/StdioClientTransport.java | 101 ++++++++++++++----
.../transport/StdioServerTransport.java | 52 ++++-----
.../McpAsyncClientResponseHandlerTests.java | 20 +++-
3 files changed, 122 insertions(+), 51 deletions(-)
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java
index aa3f6b5..01f59d1 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/StdioClientTransport.java
@@ -20,6 +20,7 @@ import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -77,6 +78,8 @@ public class StdioClientTransport implements McpTransport {
private final Sinks.Many errorSink;
+ private volatile boolean isClosing = false;
+
// visible for tests
private Consumer errorHandler = error -> logger.error("Error received: {}", error);
@@ -200,19 +203,32 @@ public class StdioClientTransport implements McpTransport {
try (BufferedReader processErrorReader = new BufferedReader(
new InputStreamReader(process.getErrorStream()))) {
String line;
- while ((line = processErrorReader.readLine()) != null) {
+ while (!isClosing && (line = processErrorReader.readLine()) != null) {
try {
logger.error("Received error line: {}", line);
- // TODO: handle errors, etc.
- this.errorSink.tryEmitNext(line);
+ if (!this.errorSink.tryEmitNext(line).isSuccess()) {
+ if (!isClosing) {
+ logger.error("Failed to emit error message");
+ }
+ break;
+ }
}
catch (Exception e) {
- throw new RuntimeException(e);
+ if (!isClosing) {
+ logger.error("Error processing error message", e);
+ }
+ break;
}
}
}
catch (IOException e) {
- throw new RuntimeException(e);
+ if (!isClosing) {
+ logger.error("Error reading from error stream", e);
+ }
+ }
+ finally {
+ isClosing = true;
+ errorSink.tryEmitComplete();
}
});
}
@@ -254,21 +270,32 @@ public class StdioClientTransport implements McpTransport {
this.inboundScheduler.schedule(() -> {
try (BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
- while ((line = processReader.readLine()) != null) {
+ while (!isClosing && (line = processReader.readLine()) != null) {
try {
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, line);
if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
- // TODO: Back off, reschedule, give up?
- throw new RuntimeException("Failed to enqueue message");
+ if (!isClosing) {
+ logger.error("Failed to enqueue inbound message");
+ }
+ break;
}
}
catch (Exception e) {
- throw new RuntimeException(e);
+ if (!isClosing) {
+ logger.error("Error processing inbound message", e);
+ }
+ break;
}
}
}
catch (IOException e) {
- throw new RuntimeException(e);
+ if (!isClosing) {
+ logger.error("Error reading from input stream", e);
+ }
+ }
+ finally {
+ isClosing = true;
+ inboundSink.tryEmitComplete();
}
});
}
@@ -284,7 +311,7 @@ public class StdioClientTransport implements McpTransport {
// want to ensure that the actual writing happens on a dedicated thread
.publishOn(outboundScheduler)
.handle((message, s) -> {
- if (message != null) {
+ if (message != null && !isClosing) {
try {
String jsonMessage = objectMapper.writeValueAsString(message);
// Escape any embedded newlines in the JSON message as per spec:
@@ -293,9 +320,12 @@ public class StdioClientTransport implements McpTransport {
// embedded newlines.
jsonMessage = jsonMessage.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n");
- this.process.getOutputStream().write(jsonMessage.getBytes(StandardCharsets.UTF_8));
- this.process.getOutputStream().write("\n".getBytes(StandardCharsets.UTF_8));
- this.process.getOutputStream().flush();
+ var os = this.process.getOutputStream();
+ synchronized (os) {
+ os.write(jsonMessage.getBytes(StandardCharsets.UTF_8));
+ os.write("\n".getBytes(StandardCharsets.UTF_8));
+ os.flush();
+ }
s.next(message);
}
catch (IOException e) {
@@ -306,7 +336,16 @@ public class StdioClientTransport implements McpTransport {
}
protected void handleOutbound(Function, Flux> outboundConsumer) {
- outboundConsumer.apply(outboundSink.asFlux()).subscribe();
+ outboundConsumer.apply(outboundSink.asFlux()).doOnComplete(() -> {
+ isClosing = true;
+ outboundSink.tryEmitComplete();
+ }).doOnError(e -> {
+ if (!isClosing) {
+ logger.error("Error in outbound processing", e);
+ isClosing = true;
+ outboundSink.tryEmitComplete();
+ }
+ }).subscribe();
}
/**
@@ -317,7 +356,18 @@ public class StdioClientTransport implements McpTransport {
*/
@Override
public Mono closeGracefully() {
- return Mono.fromFuture(() -> {
+ return Mono.fromRunnable(() -> {
+ isClosing = true;
+ logger.debug("Initiating graceful shutdown");
+ }).then(Mono.defer(() -> {
+ // First complete all sinks to stop accepting new messages
+ inboundSink.tryEmitComplete();
+ outboundSink.tryEmitComplete();
+ errorSink.tryEmitComplete();
+
+ // Give a short time for any pending messages to be processed
+ return Mono.delay(Duration.ofMillis(100));
+ })).then(Mono.fromFuture(() -> {
logger.info("Sending TERM to process");
if (this.process != null) {
this.process.destroy();
@@ -326,16 +376,23 @@ public class StdioClientTransport implements McpTransport {
else {
return CompletableFuture.failedFuture(new RuntimeException("Process not started"));
}
- }).doOnNext(process -> {
+ })).doOnNext(process -> {
if (process.exitValue() != 0) {
logger.warn("Process terminated with code " + process.exitValue());
}
}).then(Mono.fromRunnable(() -> {
- // The Threads are blocked on readLine so disposeGracefully would not
- // interrupt them, therefore we issue an async hard dispose.
- inboundScheduler.dispose();
- errorScheduler.dispose();
- outboundScheduler.dispose();
+ try {
+ // The Threads are blocked on readLine so disposeGracefully would not
+ // interrupt them, therefore we issue an async hard dispose.
+ inboundScheduler.dispose();
+ errorScheduler.dispose();
+ outboundScheduler.dispose();
+
+ logger.info("Graceful shutdown completed");
+ }
+ catch (Exception e) {
+ logger.error("Error during graceful shutdown", e);
+ }
})).then().subscribeOn(Schedulers.boundedElastic());
}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
index ed92e8d..4d9c979 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
@@ -176,9 +176,7 @@ public class StdioServerTransport implements McpTransport {
}
}
finally {
- if (!isClosing) {
- isClosing = true;
- }
+ isClosing = true;
inboundSink.tryEmitComplete();
}
});
@@ -218,9 +216,7 @@ public class StdioServerTransport implements McpTransport {
}
})
.doOnComplete(() -> {
- if (!isClosing) {
- isClosing = true;
- }
+ isClosing = true;
outboundSink.tryEmitComplete();
})
.doOnError(e -> {
@@ -240,29 +236,35 @@ public class StdioServerTransport implements McpTransport {
return Mono.fromRunnable(() -> {
isClosing = true;
logger.debug("Initiating graceful shutdown");
- // }).then(Mono.delay(Duration.ofMillis(100))).then(Mono.fromRunnable(() -> {
- }).then(Mono.fromRunnable(() -> {
- try {
- // inboundSink.tryEmitComplete();
- // outboundSink.tryEmitComplete();
+ })
+ // .then(Mono.defer(() -> {
+ // inboundSink.tryEmitComplete();
+ // outboundSink.tryEmitComplete();
+ // return Mono.delay(Duration.ofMillis(100));
+ // }))
- inboundScheduler.dispose();
- outboundScheduler.dispose();
+ .then(Mono.fromRunnable(() -> {
+ try {
- // Wait for schedulers to terminate
- if (!inboundScheduler.isDisposed()) {
- inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ inboundScheduler.dispose();
+ outboundScheduler.dispose();
+
+ // Wait for schedulers to terminate
+ if (!inboundScheduler.isDisposed()) {
+ inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ }
+ if (!outboundScheduler.isDisposed()) {
+ outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ }
+
+ logger.info("Graceful shutdown completed");
}
- if (!outboundScheduler.isDisposed()) {
- outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ catch (Exception e) {
+ logger.error("Error during graceful shutdown", e);
}
-
- logger.info("Graceful shutdown completed");
- }
- catch (Exception e) {
- logger.error("Error during graceful shutdown", e);
- }
- })).then().subscribeOn(Schedulers.boundedElastic());
+ }))
+ .then()
+ .subscribeOn(Schedulers.boundedElastic());
}
@Override
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
index c818a47..d13a8bd 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
@@ -49,11 +49,11 @@ class McpAsyncClientResponseHandlerTests {
private final AtomicInteger inboundMessageCount = new AtomicInteger(0);
- private Sinks.Many outgoing = Sinks.many().multicast().onBackpressureBuffer();
+ private final Sinks.Many outgoing = Sinks.many().multicast().onBackpressureBuffer();
- private Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer();
+ private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer();
- private Flux outboundView = outgoing.asFlux().cache(1);
+ private final Flux outboundView = outgoing.asFlux().cache(1);
public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) {
if (inbound.tryEmitNext(message).isFailure()) {
@@ -82,17 +82,29 @@ class McpAsyncClientResponseHandlerTests {
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.empty();
+ return Mono.defer(() -> {
+ connected = false;
+ outgoing.tryEmitComplete();
+ inbound.tryEmitComplete();
+ return Mono.empty();
+ });
}
@Override
From 36b951558d7044c5b4d5366cabd4c165b87982de Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sun, 29 Dec 2024 17:53:40 +0100
Subject: [PATCH 008/514] refactor: improve MCP client code organization and
robustness
- Add null parameter validation in client builders
- Enhance JavaDoc documentation for tools and resources
- Reorganize McpAsyncClient methods by functionality
- Fix sampling handler test setup
---
.../ai/mcp/client/McpAsyncClient.java | 334 ++++++++++--------
.../ai/mcp/client/McpClient.java | 8 +
.../ai/mcp/client/McpSyncClient.java | 26 +-
.../ai/mcp/spec/McpSchema.java | 48 ++-
.../ai/mcp/spec/McpTransport.java | 7 +
.../client/AbstractMcpAsyncClientTests.java | 4 +-
.../McpAsyncClientResponseHandlerTests.java | 1 -
7 files changed, 268 insertions(+), 160 deletions(-)
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java
index fd39051..5b50266 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java
@@ -47,6 +47,7 @@ import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
import org.springframework.ai.mcp.spec.McpSchema.PaginatedRequest;
import org.springframework.ai.mcp.spec.McpSchema.Root;
import org.springframework.ai.mcp.spec.McpTransport;
+import org.springframework.ai.mcp.util.Assert;
import org.springframework.ai.mcp.util.Utils;
/**
@@ -119,6 +120,10 @@ public class McpAsyncClient {
List>> promptsChangeConsumers,
Function samplingHandler) {
+ Assert.notNull(transport, "Transport must not be null");
+ Assert.notNull(requestTimeout, "Request timeout must not be null");
+ Assert.notNull(clientInfo, "Client info must not be null");
+
this.clientInfo = clientInfo;
this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities
@@ -177,133 +182,9 @@ public class McpAsyncClient {
}
- public Mono addRoot(Root root) {
-
- if (root == null) {
- return Mono.error(new McpError("Root must not be null"));
- }
-
- if (this.clientCapabilities.roots() == null) {
- return Mono.error(new McpError("Client must be configured with roots capabilities"));
- }
-
- if (this.roots.containsKey(root.uri())) {
- return Mono.error(new McpError("Root with uri '" + root.uri() + "' already exists"));
- }
-
- this.roots.put(root.uri(), root);
-
- logger.info("Added root: {}", root);
-
- if (this.clientCapabilities.roots().listChanged()) {
- return this.rootsListChangedNotification();
- }
- return Mono.empty();
- }
-
- public Mono removeRoot(String rootUri) {
-
- if (rootUri == null) {
- return Mono.error(new McpError("Root uri must not be null"));
- }
-
- if (this.clientCapabilities.roots() == null) {
- return Mono.error(new McpError("Client must be configured with roots capabilities"));
- }
-
- Root removed = this.roots.remove(rootUri);
-
- if (removed != null) {
- logger.info("Removed Root: {}", rootUri);
- if (this.clientCapabilities.roots().listChanged()) {
- return this.rootsListChangedNotification();
- }
- return Mono.empty();
- }
- return Mono.error(new McpError("Root with uri '" + rootUri + "' not found"));
- }
-
- private RequestHandler samplingCreateMessageHandler() {
- return params -> {
- McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params,
- new TypeReference() {
- });
-
- CreateMessageResult response = this.samplingHandler.apply(request);
-
- return Mono.just(response);
- };
- }
-
- private RequestHandler rootsListRequestHandler() {
- return params -> {
- McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
- new TypeReference() {
- });
-
- List roots = this.roots.values().stream().toList();
-
- return Mono.just(roots);
- };
- }
-
- private NotificationHandler toolsChangeNotificationHandler(
- List>> toolsChangeConsumers) {
-
- return new NotificationHandler() {
- @Override
- public Mono handle(Object params) {
- // TODO: add support for cursor/pagination
- return listTools().flatMap(listToolsResult -> Mono.fromRunnable(() -> {
- for (Consumer> toolsChangeConsumer : toolsChangeConsumers) {
- toolsChangeConsumer.accept(listToolsResult.tools());
- }
- }).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
- logger.error("Error handling tools list change notification", error);
- return Mono.empty();
- }).then(); // Convert to Mono
- }
- };
- };
-
- private NotificationHandler resourcesChangeNotificationHandler(
- List>> resourcesChangeConsumers) {
-
- return new NotificationHandler() {
- @Override
- public Mono handle(Object params) {
- // TODO: add support for cursor/pagination
- return listResources().flatMap(listResourcesResult -> Mono.fromRunnable(() -> {
- for (Consumer> resourceChangeConsumer : resourcesChangeConsumers) {
- resourceChangeConsumer.accept(listResourcesResult.resources());
- }
- }).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
- logger.error("Error handling resources list change notification", error);
- return Mono.empty();
- }).then(); // Convert to Mono
- }
- };
- };
-
- private NotificationHandler promptsChangeNotificationHandler(
- List>> promptsChangeConsumers) {
-
- return new NotificationHandler() {
- @Override
- public Mono handle(Object params) {
- // TODO: add support for cursor/pagination
- return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> {
- for (Consumer> promptChangeConsumer : promptsChangeConsumers) {
- promptChangeConsumer.accept(listPromptsResult.prompts());
- }
- }).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
- logger.error("Error handling prompts list change notification", error);
- return Mono.empty();
- }).then(); // Convert to Mono
- }
- };
- };
-
+ // --------------------------
+ // Lifecycle
+ // --------------------------
/**
* The initialization phase MUST be the first interaction between client and server.
* During this phase, the client and server:
@@ -358,13 +239,18 @@ public class McpAsyncClient {
});
}
- /**
- * Send a roots/list_changed notification.
- */
- public Mono rootsListChangedNotification() {
- return this.mcpSession.sendNotification("notifications/roots/list_changed");
+ public void close() {
+ this.mcpSession.close();
}
+ public Mono closeGracefully() {
+ return this.mcpSession.closeGracefully();
+ }
+
+ // --------------------------
+ // Basic Utilites
+ // --------------------------
+
/**
* Send a synchronous ping request.
*/
@@ -373,6 +259,90 @@ public class McpAsyncClient {
});
}
+ // --------------------------
+ // Roots
+ // --------------------------
+ public Mono addRoot(Root root) {
+
+ if (root == null) {
+ return Mono.error(new McpError("Root must not be null"));
+ }
+
+ if (this.clientCapabilities.roots() == null) {
+ return Mono.error(new McpError("Client must be configured with roots capabilities"));
+ }
+
+ if (this.roots.containsKey(root.uri())) {
+ return Mono.error(new McpError("Root with uri '" + root.uri() + "' already exists"));
+ }
+
+ this.roots.put(root.uri(), root);
+
+ logger.info("Added root: {}", root);
+
+ if (this.clientCapabilities.roots().listChanged()) {
+ return this.rootsListChangedNotification();
+ }
+ return Mono.empty();
+ }
+
+ public Mono removeRoot(String rootUri) {
+
+ if (rootUri == null) {
+ return Mono.error(new McpError("Root uri must not be null"));
+ }
+
+ if (this.clientCapabilities.roots() == null) {
+ return Mono.error(new McpError("Client must be configured with roots capabilities"));
+ }
+
+ Root removed = this.roots.remove(rootUri);
+
+ if (removed != null) {
+ logger.info("Removed Root: {}", rootUri);
+ if (this.clientCapabilities.roots().listChanged()) {
+ return this.rootsListChangedNotification();
+ }
+ return Mono.empty();
+ }
+ return Mono.error(new McpError("Root with uri '" + rootUri + "' not found"));
+ }
+
+ /**
+ * Manually, send a roots/list_changed notification. The addRoot and removeRoot
+ * methods automatically send the roots/list_changed notification.
+ */
+ public Mono rootsListChangedNotification() {
+ return this.mcpSession.sendNotification("notifications/roots/list_changed");
+ }
+
+ private RequestHandler rootsListRequestHandler() {
+ return params -> {
+ McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ List roots = this.roots.values().stream().toList();
+
+ return Mono.just(roots);
+ };
+ }
+
+ // --------------------------
+ // Sampling
+ // --------------------------
+ private RequestHandler samplingCreateMessageHandler() {
+ return params -> {
+ McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params,
+ new TypeReference() {
+ });
+
+ CreateMessageResult response = this.samplingHandler.apply(request);
+
+ return Mono.just(response);
+ };
+ }
+
// --------------------------
// Tools
// --------------------------
@@ -383,32 +353,74 @@ public class McpAsyncClient {
};
/**
- * Send a tools/call request.
- * @param callToolRequest the call tool request.
- * @return the call tool result.
+ * Calls a tool provided by the server. Tools enable servers to expose executable
+ * functionality that can interact with external systems, perform computations, and
+ * take actions in the real world.
+ * @param callToolRequest The request containing: - name: The name of the tool to call
+ * (must match a tool name from tools/list) - arguments: Arguments that conform to the
+ * tool's input schema
+ * @return A Mono that emits the tool execution result containing: - content: List of
+ * content items (text, images, or embedded resources) representing the tool's output
+ * - isError: Boolean indicating if the execution failed (true) or succeeded
+ * (false/absent)
*/
public Mono callTool(McpSchema.CallToolRequest callToolRequest) {
return this.mcpSession.sendRequest("tools/call", callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
}
/**
- * Send a tools/list request.
- * @return the list of tools result.
+ * Retrieves the list of all tools provided by the server.
+ * @return A Mono that emits the list of tools result containing: - tools: List of
+ * available tools, each with a name, description, and input schema - nextCursor:
+ * Optional cursor for pagination if more tools are available
*/
public Mono listTools() {
return this.listTools(null);
}
/**
- * Send a tools/list request.
- * @param cursor the cursor
- * @return the list of tools result.
+ * Retrieves a paginated list of tools provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @return A Mono that emits the list of tools result containing: - tools: List of
+ * available tools, each with a name, description, and input schema - nextCursor:
+ * Optional cursor for pagination if more tools are available
*/
public Mono listTools(String cursor) {
return this.mcpSession.sendRequest("tools/list", new McpSchema.PaginatedRequest(cursor),
LIST_TOOLS_RESULT_TYPE_REF);
}
+ /**
+ * Creates a notification handler for tools/list_changed notifications from the
+ * server. When the server's available tools change, it sends a notification to inform
+ * connected clients. This handler automatically fetches the updated tool list and
+ * distributes it to all registered consumers.
+ * @param toolsChangeConsumers List of consumers that will be notified when the tools
+ * list changes. Each consumer receives the complete updated list of tools.
+ * @return A NotificationHandler that processes tools/list_changed notifications by:
+ * 1. Fetching the current list of tools from the server 2. Distributing the updated
+ * list to all registered consumers 3. Handling any errors that occur during this
+ * process
+ */
+ private NotificationHandler toolsChangeNotificationHandler(
+ List>> toolsChangeConsumers) {
+
+ return new NotificationHandler() {
+ @Override
+ public Mono handle(Object params) {
+ // TODO: add support for cursor/pagination
+ return listTools().flatMap(listToolsResult -> Mono.fromRunnable(() -> {
+ for (Consumer> toolsChangeConsumer : toolsChangeConsumers) {
+ toolsChangeConsumer.accept(listToolsResult.tools());
+ }
+ }).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
+ logger.error("Error handling tools list change notification", error);
+ return Mono.empty();
+ }).then(); // Convert to Mono
+ }
+ };
+ };
+
// --------------------------
// Resources
// --------------------------
@@ -512,6 +524,25 @@ public class McpAsyncClient {
return this.mcpSession.sendRequest("resources/unsubscribe", unsubscribeRequest, VOID_TYPE_REFERENCE);
}
+ private NotificationHandler resourcesChangeNotificationHandler(
+ List>> resourcesChangeConsumers) {
+
+ return new NotificationHandler() {
+ @Override
+ public Mono handle(Object params) {
+ // TODO: add support for cursor/pagination
+ return listResources().flatMap(listResourcesResult -> Mono.fromRunnable(() -> {
+ for (Consumer> resourceChangeConsumer : resourcesChangeConsumers) {
+ resourceChangeConsumer.accept(listResourcesResult.resources());
+ }
+ }).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
+ logger.error("Error handling resources list change notification", error);
+ return Mono.empty();
+ }).then(); // Convert to Mono
+ }
+ };
+ };
+
// --------------------------
// Prompts
// --------------------------
@@ -556,12 +587,23 @@ public class McpAsyncClient {
return this.mcpSession.sendNotification("notifications/prompts/list_changed");
}
- public void close() {
- this.mcpSession.close();
- }
+ private NotificationHandler promptsChangeNotificationHandler(
+ List>> promptsChangeConsumers) {
- public Mono closeGracefully() {
- return this.mcpSession.closeGracefully();
- }
+ return new NotificationHandler() {
+ @Override
+ public Mono handle(Object params) {
+ // TODO: add support for cursor/pagination
+ return listPrompts().flatMap(listPromptsResult -> Mono.fromRunnable(() -> {
+ for (Consumer> promptChangeConsumer : promptsChangeConsumers) {
+ promptChangeConsumer.accept(listPromptsResult.prompts());
+ }
+ }).subscribeOn(Schedulers.boundedElastic())).onErrorResume(error -> {
+ logger.error("Error handling prompts list change notification", error);
+ return Mono.empty();
+ }).then(); // Convert to Mono
+ }
+ };
+ };
}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
index f0ef753..3c4663a 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
@@ -108,16 +108,19 @@ public interface McpClient {
}
public Builder capabilities(ClientCapabilities capabilities) {
+ Assert.notNull(capabilities, "Capabilities must not be null");
this.capabilities = capabilities;
return this;
}
public Builder clientInfo(Implementation clientInfo) {
+ Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
return this;
}
public Builder roots(List roots) {
+ Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
@@ -125,6 +128,7 @@ public interface McpClient {
}
public Builder roots(Root... roots) {
+ Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
this.roots.put(root.uri(), root);
}
@@ -132,21 +136,25 @@ public interface McpClient {
}
public Builder sampling(Function samplingHandler) {
+ Assert.notNull(samplingHandler, "Sampling handler must not be null");
this.samplingHandler = samplingHandler;
return this;
}
public Builder toolsChangeConsumer(Consumer> toolsChangeConsumer) {
+ Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null");
this.toolsChangeConsumers.add(toolsChangeConsumer);
return this;
}
public Builder resourcesChangeConsumer(Consumer> resourcesChangeConsumer) {
+ Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null");
this.resourcesChangeConsumers.add(resourcesChangeConsumer);
return this;
}
public Builder promptsChangeConsumer(Consumer> promptsChangeConsumer) {
+ Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null");
this.promptsChangeConsumers.add(promptsChangeConsumer);
return this;
}
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
index 18daf13..54fed7b 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
@@ -130,26 +130,36 @@ public class McpSyncClient implements AutoCloseable {
// Tools
// --------------------------
/**
- * Send a tools/call request.
- * @param callToolRequest the call tool request.
- * @return the call tool result.
+ * Calls a tool provided by the server. Tools enable servers to expose executable
+ * functionality that can interact with external systems, perform computations, and
+ * take actions in the real world.
+ * @param callToolRequest The request containing: - name: The name of the tool to call
+ * (must match a tool name from tools/list) - arguments: Arguments that conform to the
+ * tool's input schema
+ * @return The tool execution result containing: - content: List of content items
+ * (text, images, or embedded resources) representing the tool's output - isError:
+ * Boolean indicating if the execution failed (true) or succeeded (false/absent)
*/
public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest callToolRequest) {
return this.delegate.callTool(callToolRequest).block();
}
/**
- * Send a tools/list request.
- * @return the list of tools result.
+ * Retrieves the list of all tools provided by the server.
+ * @return The list of tools result containing: - tools: List of available tools, each
+ * with a name, description, and input schema - nextCursor: Optional cursor for
+ * pagination if more tools are available
*/
public McpSchema.ListToolsResult listTools() {
return this.delegate.listTools().block();
}
/**
- * Send a tools/list request.
- * @param cursor the cursor
- * @return the list of tools result.
+ * Retrieves a paginated list of tools provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @return The list of tools result containing: - tools: List of available tools, each
+ * with a name, description, and input schema - nextCursor: Optional cursor for
+ * pagination if more tools are available
*/
public McpSchema.ListToolsResult listTools(String cursor) {
return this.delegate.listTools(cursor).block();
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
index 7d261c3..ece13f6 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
@@ -364,6 +364,8 @@ public class McpSchema {
* by clients to improve the LLM's understanding of available resources. It can be
* thought of like a "hint" to the model.
* @param mimeType The MIME type of this resource, if known.
+ * @param annotations Optional annotations for the client. The client can use
+ * annotations to inform how objects are used or displayed.
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -387,6 +389,8 @@ public class McpSchema {
* by clients to improve the LLM's understanding of available resources. It can be
* thought of like a "hint" to the model.
* @param mimeType The MIME type of this resource, if known.
+ * @param annotations Optional annotations for the client. The client can use
+ * annotations to inform how objects are used or displayed.
* @see RFC 6570
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@@ -574,12 +578,32 @@ public class McpSchema {
// ---------------------------
// Tool Interfaces
// ---------------------------
+ /**
+ * The server's response to a tools/list request from the client.
+ *
+ * @param tools A list of tools that the server provides.
+ * @param nextCursor An optional cursor for pagination. If present, indicates there
+ * are more tools available.
+ */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record ListToolsResult( // @formatter:off
@JsonProperty("tools") List tools,
@JsonProperty("nextCursor") String nextCursor) {
}// @formatter:on
+ /**
+ * Represents a tool that the server provides. Tools enable servers to expose
+ * executable functionality to the system. Through these tools, you can interact with
+ * external systems, perform computations, and take actions in the real world.
+ *
+ * @param name A unique identifier for the tool. This name is used when calling the
+ * tool.
+ * @param description A human-readable description of what the tool does. This can be
+ * used by clients to improve the LLM's understanding of available tools.
+ * @param inputSchema A JSON Schema object that describes the expected structure of
+ * the arguments when calling this tool. This allows clients to validate tool
+ * arguments before sending them to the server.
+ */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record Tool( // @formatter:off
@JsonProperty("name") String name,
@@ -601,12 +625,28 @@ public class McpSchema {
}
}
+ /**
+ * Used by the client to call a tool provided by the server.
+ *
+ * @param name The name of the tool to call. This must match a tool name from
+ * tools/list.
+ * @param arguments Arguments to pass to the tool. These must conform to the tool's
+ * input schema.
+ */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record CallToolRequest(// @formatter:off
@JsonProperty("name") String name,
@JsonProperty("arguments") Map arguments) implements Request {
}// @formatter:off
+ /**
+ * The server's response to a tools/call request from the client.
+ *
+ * @param content A list of content items representing the tool's output. Each item can be text, an image,
+ * or an embedded resource.
+ * @param isError If true, indicates that the tool execution failed and the content contains error information.
+ * If false or absent, indicates successful execution.
+ */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record CallToolResult( // @formatter:off
@JsonProperty("content") List content,
@@ -643,16 +683,16 @@ public class McpSchema {
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record CreateMessageResult(// @formatter:off
- @JsonProperty("role") Role role,
- @JsonProperty("content") Content content,
- @JsonProperty("model") String model,
+ @JsonProperty("role") Role role,
+ @JsonProperty("content") Content content,
+ @JsonProperty("model") String model,
@JsonProperty("stopReason") StopReason stopReason) {
public enum StopReason {
@JsonProperty("end_turn") END_TURN,
@JsonProperty("stop_sequence") STOP_SEQUENCE,
@JsonProperty("max_tokens") MAX_TOKENS
- }
+ }
}// @formatter:on
// ---------------------------
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpTransport.java
index f81a6c6..48c6965 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpTransport.java
@@ -93,6 +93,13 @@ public interface McpTransport {
*/
Mono sendMessage(JSONRPCMessage message);
+ /**
+ * Unmarshals the given data into an object of the specified type.
+ * @param the type of the object to unmarshal
+ * @param data the data to unmarshal
+ * @param typeRef the type reference for the object to unmarshal
+ * @return the unmarshalled object
+ */
T unmarshalFrom(Object data, TypeReference typeRef);
}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
index 72c683a..b7b26a2 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/AbstractMcpAsyncClientTests.java
@@ -22,6 +22,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
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;
@@ -225,7 +226,8 @@ public abstract class AbstractMcpAsyncClientTests {
.hasMessageContaining("Root with uri 'nonexistent-uri' not found");
}
- // @Test
+ @Test
+ @Disabled
void testReadResource() {
StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> {
if (!resources.resources().isEmpty()) {
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
index d13a8bd..282744c 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/client/McpAsyncClientResponseHandlerTests.java
@@ -365,7 +365,6 @@ class McpAsyncClientResponseHandlerTests {
// Create client with sampling capability but null handler
McpAsyncClient asyncMcpClient = McpClient.using(transport)
.capabilities(ClientCapabilities.builder().sampling().build())
- .sampling(null) // Null handler
.async();
// Create a mock create message request
From f5e121f3c0279c912b09fe33b4be4ce7a68ee14f Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sun, 29 Dec 2024 18:16:15 +0100
Subject: [PATCH 009/514] Release version 0.3.0
---
mcp/pom.xml | 2 +-
pom.xml | 2 +-
spring-ai-mcp-sample/pom.xml | 2 +-
spring-ai-mcp/pom.xml | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/mcp/pom.xml b/mcp/pom.xml
index eda6f43..eb1802f 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.3.0-SNAPSHOT
+ 0.3.0
mcp
jar
diff --git a/pom.xml b/pom.xml
index 3c1151f..8326898 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.3.0-SNAPSHOT
+ 0.3.0
pom
https://github.com/spring-projects-experimental/spring-ai-mcp
diff --git a/spring-ai-mcp-sample/pom.xml b/spring-ai-mcp-sample/pom.xml
index 85199ea..dad8702 100644
--- a/spring-ai-mcp-sample/pom.xml
+++ b/spring-ai-mcp-sample/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.3.0-SNAPSHOT
+ 0.3.0
spring-ai-mcp-sample
diff --git a/spring-ai-mcp/pom.xml b/spring-ai-mcp/pom.xml
index 1f8ddf3..f3c17dc 100644
--- a/spring-ai-mcp/pom.xml
+++ b/spring-ai-mcp/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.3.0-SNAPSHOT
+ 0.3.0
spring-ai-mcp
Spring AI MCP
From e6cb56c4b15b5415dcb12ecb93b5adbb702f0c18 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sun, 29 Dec 2024 18:17:34 +0100
Subject: [PATCH 010/514] Next development version
---
mcp/pom.xml | 2 +-
pom.xml | 2 +-
spring-ai-mcp-sample/pom.xml | 2 +-
spring-ai-mcp/pom.xml | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/mcp/pom.xml b/mcp/pom.xml
index eb1802f..c5c2d54 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.3.0
+ 0.4.0-SNAPSHOT
mcp
jar
diff --git a/pom.xml b/pom.xml
index 8326898..4a1823b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.3.0
+ 0.4.0-SNAPSHOT
pom
https://github.com/spring-projects-experimental/spring-ai-mcp
diff --git a/spring-ai-mcp-sample/pom.xml b/spring-ai-mcp-sample/pom.xml
index dad8702..70aa6d8 100644
--- a/spring-ai-mcp-sample/pom.xml
+++ b/spring-ai-mcp-sample/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.3.0
+ 0.4.0-SNAPSHOT
spring-ai-mcp-sample
diff --git a/spring-ai-mcp/pom.xml b/spring-ai-mcp/pom.xml
index f3c17dc..67caf72 100644
--- a/spring-ai-mcp/pom.xml
+++ b/spring-ai-mcp/pom.xml
@@ -6,7 +6,7 @@
org.springframework.experimental
mcp-parent
- 0.3.0
+ 0.4.0-SNAPSHOT
spring-ai-mcp
Spring AI MCP
From f7551839c55b6ebf949f537b66c3ec0301b0aac5 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sun, 29 Dec 2024 18:56:38 +0100
Subject: [PATCH 011/514] docs Update pom version in docs
---
README.md | 6 +++---
mcp/README.md | 2 +-
spring-ai-mcp-sample/README.md | 6 +++---
.../springframework/ai/mcp/sample/client/ClientStdio.java | 2 +-
spring-ai-mcp/README.md | 2 +-
5 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 330e838..b799c3d 100644
--- a/README.md
+++ b/README.md
@@ -42,14 +42,14 @@ Add the following dependencies to your Maven project:
org.springframework.experimental
mcp
- 0.3.0-SNAPSHOT
+ 0.4.0-SNAPSHOT
org.springframework.experimental
spring-ai-mcp
- 0.3.0-SNAPSHOT
+ 0.4.0-SNAPSHOT
```
@@ -85,7 +85,7 @@ mvn test
## Project Information
- **Group ID**: org.springframework.experimental
-- **Version**: 0.3.0-SNAPSHOT
+- **Version**: 0.4.0-SNAPSHOT
- **Java Version**: 17
- **Spring AI Version**: 1.0.0-M4
diff --git a/mcp/README.md b/mcp/README.md
index 11a2ad1..f0bf8a9 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -26,7 +26,7 @@ Add the following dependency to your Maven project:
org.springframework.experimental
mcp
- 0.3.0-SNAPSHOT
+ 0.4.0-SNAPSHOT
```
diff --git a/spring-ai-mcp-sample/README.md b/spring-ai-mcp-sample/README.md
index 4c53b48..632e253 100644
--- a/spring-ai-mcp-sample/README.md
+++ b/spring-ai-mcp-sample/README.md
@@ -28,7 +28,7 @@ The server can be started in two transport modes, controlled by the `transport.m
### Stdio Mode (Default)
```bash
-java -Dtransport.mode=stdio -jar target/spring-ai-mcp-sample-0.3.0-SNAPSHOT.jar
+java -Dtransport.mode=stdio -jar target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar
```
The Stdio mode server is automatically started by the client - no explicit server startup is needed.
@@ -38,7 +38,7 @@ In Stdio mode the server must not emit any messages/logs to the console (e.g. st
### SSE Mode
```bash
-java -Dtransport.mode=sse -jar target/spring-ai-mcp-sample-0.3.0-SNAPSHOT.jar
+java -Dtransport.mode=sse -jar target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar
```
## Sample Clients
@@ -49,7 +49,7 @@ The project includes example clients for both transport modes:
```java
var stdioParams = ServerParameters.builder("java")
.args("-Dtransport.mode=stdio", "-jar",
- "target/spring-ai-mcp-sample-0.3.0-SNAPSHOT.jar")
+ "target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar")
.build();
var transport = new StdioClientTransport(stdioParams);
diff --git a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java
index 2aeb1d7..7777359 100644
--- a/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java
+++ b/spring-ai-mcp-sample/src/main/java/org/springframework/ai/mcp/sample/client/ClientStdio.java
@@ -28,7 +28,7 @@ public class ClientStdio {
var stdioParams = ServerParameters.builder("java")
.args("-Dtransport.mode=stdio", "-jar",
- "spring-ai-mcp-sample/target/spring-ai-mcp-sample-0.3.0-SNAPSHOT.jar")
+ "spring-ai-mcp-sample/target/spring-ai-mcp-sample-0.4.0-SNAPSHOT.jar")
.build();
var transport = new StdioClientTransport(stdioParams);
diff --git a/spring-ai-mcp/README.md b/spring-ai-mcp/README.md
index 29ade4c..07229bd 100644
--- a/spring-ai-mcp/README.md
+++ b/spring-ai-mcp/README.md
@@ -31,7 +31,7 @@ To use this module, add the following dependency to your Maven project:
org.springframework.experimental
spring-ai-mcp
- 0.3.0-SNAPSHOT
+ 0.4.0-SNAPSHOT
```
From 2de1d637c070ee90f018f8b4c98e89dc948dd7d4 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sun, 29 Dec 2024 21:05:57 +0100
Subject: [PATCH 012/514] Fix javadoc issues
---
.../ai/mcp/client/McpAsyncClient.java | 82 ++++++++++++-------
.../ai/mcp/client/McpSyncClient.java | 2 +
.../client/transport/SseClientTransport.java | 4 +-
.../ai/mcp/server/McpAsyncServer.java | 23 ++++--
.../ai/mcp/spec/McpSchema.java | 30 ++++++-
.../springframework/ai/mcp/util/Assert.java | 7 +-
.../springframework/ai/mcp/util/Utils.java | 4 +-
7 files changed, 111 insertions(+), 41 deletions(-)
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java
index 5b50266..add1ad6 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpAsyncClient.java
@@ -84,8 +84,8 @@ public class McpAsyncClient {
private final ConcurrentHashMap roots;
/**
- * MCP provides a standardized way for servers to request LLM sampling (“completions”
- * or “generations”) from language models via clients. This flow allows clients to
+ * MCP provides a standardized way for servers to request LLM sampling ("completions"
+ * or "generations") from language models via clients. This flow allows clients to
* maintain control over model access, selection, and permissions while enabling
* servers to leverage AI capabilities—with no server API keys necessary. Servers can
* request text or image-based interactions and optionally include context from MCP
@@ -108,10 +108,13 @@ public class McpAsyncClient {
* timeout.
* @param transport the transport to use.
* @param requestTimeout the session request-response timeout.
- * @param rootsListProviders the list of suppliers that provide the list of roots
- * backing the roots list request.
- * @param rootsListChangedNotification whether the client supports roots/list_changed
- * notification.
+ * @param clientInfo the client implementation information.
+ * @param clientCapabilities the client capabilities.
+ * @param roots the roots.
+ * @param toolsChangeConsumers the tools change consumers.
+ * @param resourcesChangeConsumers the resources change consumers.
+ * @param promptsChangeConsumers the prompts change consumers.
+ * @param samplingHandler the sampling handler.
*/
public McpAsyncClient(McpTransport transport, Duration requestTimeout, Implementation clientInfo,
ClientCapabilities clientCapabilities, Map roots,
@@ -215,9 +218,9 @@ public class McpAsyncClient {
*/
public Mono initialize() {
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
- McpSchema.LATEST_PROTOCOL_VERSION,
- this.clientCapabilities,
- this.clientInfo); // @formatter:on
+ McpSchema.LATEST_PROTOCOL_VERSION,
+ this.clientCapabilities,
+ this.clientInfo); // @formatter:on
Mono result = this.mcpSession.sendRequest("initialize", initializeRequest,
new TypeReference() {
@@ -239,10 +242,17 @@ public class McpAsyncClient {
});
}
+ /**
+ * Closes the client connection immediately.
+ */
public void close() {
this.mcpSession.close();
}
+ /**
+ * Gracefully closes the client connection.
+ * @return A Mono that completes when the connection is closed
+ */
public Mono closeGracefully() {
return this.mcpSession.closeGracefully();
}
@@ -252,7 +262,8 @@ public class McpAsyncClient {
// --------------------------
/**
- * Send a synchronous ping request.
+ * Sends a ping request to the server.
+ * @return A Mono that completes with the server's ping response
*/
public Mono ping() {
return this.mcpSession.sendRequest("ping", null, new TypeReference() {
@@ -262,6 +273,11 @@ public class McpAsyncClient {
// --------------------------
// Roots
// --------------------------
+ /**
+ * Adds a new root to the client's root list.
+ * @param root The root to add
+ * @return A Mono that completes when the root is added and notifications are sent
+ */
public Mono addRoot(Root root) {
if (root == null) {
@@ -286,6 +302,11 @@ public class McpAsyncClient {
return Mono.empty();
}
+ /**
+ * Removes a root from the client's root list.
+ * @param rootUri The URI of the root to remove
+ * @return A Mono that completes when the root is removed and notifications are sent
+ */
public Mono removeRoot(String rootUri) {
if (rootUri == null) {
@@ -309,8 +330,9 @@ public class McpAsyncClient {
}
/**
- * Manually, send a roots/list_changed notification. The addRoot and removeRoot
+ * Manually sends a roots/list_changed notification. The addRoot and removeRoot
* methods automatically send the roots/list_changed notification.
+ * @return A Mono that completes when the notification is sent
*/
public Mono rootsListChangedNotification() {
return this.mcpSession.sendNotification("notifications/roots/list_changed");
@@ -436,7 +458,7 @@ public class McpAsyncClient {
/**
* Send a resources/list request.
- * @return the list of resources result.
+ * @return A Mono that completes with the list of resources result
*/
public Mono listResources() {
return this.listResources(null);
@@ -444,8 +466,8 @@ public class McpAsyncClient {
/**
* Send a resources/list request.
- * @param cursor the cursor
- * @return the list of resources result.
+ * @param cursor the cursor for pagination
+ * @return A Mono that completes with the list of resources result
*/
public Mono listResources(String cursor) {
return this.mcpSession.sendRequest("resources/list", new McpSchema.PaginatedRequest(cursor),
@@ -455,7 +477,7 @@ public class McpAsyncClient {
/**
* Send a resources/read request.
* @param resource the resource to read
- * @return the resource content.
+ * @return A Mono that completes with the resource content
*/
public Mono readResource(McpSchema.Resource resource) {
return this.readResource(new McpSchema.ReadResourceRequest(resource.uri()));
@@ -463,8 +485,8 @@ public class McpAsyncClient {
/**
* Send a resources/read request.
- * @param readResourceRequest the read resource request.
- * @return the resource content.
+ * @param readResourceRequest the read resource request
+ * @return A Mono that completes with the resource content
*/
public Mono readResource(McpSchema.ReadResourceRequest readResourceRequest) {
return this.mcpSession.sendRequest("resources/read", readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF);
@@ -475,7 +497,7 @@ public class McpAsyncClient {
* templates. Arguments may be auto-completed through the completion API.
*
* Request a list of resource templates the server has.
- * @return the list of resource templates result.
+ * @return A Mono that completes with the list of resource templates result
*/
public Mono listResourceTemplates() {
return this.listResourceTemplates(null);
@@ -486,8 +508,8 @@ public class McpAsyncClient {
* templates. Arguments may be auto-completed through the completion API.
*
* Request a list of resource templates the server has.
- * @param cursor the cursor
- * @return the list of resource templates result.
+ * @param cursor the cursor for pagination
+ * @return A Mono that completes with the list of resource templates result
*/
public Mono listResourceTemplates(String cursor) {
return this.mcpSession.sendRequest("resources/templates/list", new McpSchema.PaginatedRequest(cursor),
@@ -496,7 +518,8 @@ public class McpAsyncClient {
/**
* List Changed Notification. When the list of available resources changes, servers
- * that declared the listChanged capability SHOULD send a notification:
+ * that declared the listChanged capability SHOULD send a notification.
+ * @return A Mono that completes when the notification is sent
*/
public Mono sendResourcesListChanged() {
return this.mcpSession.sendNotification("notifications/resources/list_changed");
@@ -509,7 +532,8 @@ public class McpAsyncClient {
*
* Send a resources/subscribe request.
* @param subscribeRequest the subscribe request contains the uri of the resource to
- * subscribe to.
+ * subscribe to
+ * @return A Mono that completes when the subscription is complete
*/
public Mono subscribeResource(McpSchema.SubscribeRequest subscribeRequest) {
return this.mcpSession.sendRequest("resources/subscribe", subscribeRequest, VOID_TYPE_REFERENCE);
@@ -518,7 +542,8 @@ public class McpAsyncClient {
/**
* Send a resources/unsubscribe request.
* @param unsubscribeRequest the unsubscribe request contains the uri of the resource
- * to unsubscribe from.
+ * to unsubscribe from
+ * @return A Mono that completes when the unsubscription is complete
*/
public Mono unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) {
return this.mcpSession.sendRequest("resources/unsubscribe", unsubscribeRequest, VOID_TYPE_REFERENCE);
@@ -554,7 +579,7 @@ public class McpAsyncClient {
/**
* List all available prompts.
- * @return the list of prompts result.
+ * @return A Mono that completes with the list of prompts result
*/
public Mono listPrompts() {
return this.listPrompts(null);
@@ -562,8 +587,8 @@ public class McpAsyncClient {
/**
* List all available prompts.
- * @param cursor the cursor
- * @return the list of prompts result.
+ * @param cursor the cursor for pagination
+ * @return A Mono that completes with the list of prompts result
*/
public Mono listPrompts(String cursor) {
return this.mcpSession.sendRequest("prompts/list", new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF);
@@ -571,8 +596,8 @@ public class McpAsyncClient {
/**
* Get a prompt by its id.
- * @param getPromptRequest the get prompt request.
- * @return the get prompt result.
+ * @param getPromptRequest the get prompt request
+ * @return A Mono that completes with the get prompt result
*/
public Mono getPrompt(GetPromptRequest getPromptRequest) {
return this.mcpSession.sendRequest("prompts/get", getPromptRequest, GET_PROMPT_RESULT_TYPE_REF);
@@ -582,6 +607,7 @@ public class McpAsyncClient {
* (Server) An optional notification from the server to the client, informing it that
* the list of prompts it offers has changed. This may be issued by servers without
* any previous subscription from the client.
+ * @return A Mono that completes when the notification is sent
*/
public Mono promptListChangedNotification() {
return this.mcpSession.sendNotification("notifications/prompts/list_changed");
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
index 54fed7b..88b87e0 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpSyncClient.java
@@ -28,6 +28,8 @@ import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
import org.springframework.ai.mcp.util.Assert;
/**
+ * A synchronous client for the Model Context Protocol (MCP).
+ *
* @author Dariusz Jędrzejczyk
* @author Christian Tzolov
*/
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java
index 8438958..a77528b 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/transport/SseClientTransport.java
@@ -60,8 +60,8 @@ import org.springframework.web.reactive.function.client.WebClient;
*
* The client establishes an SSE connection to the server's /sse endpoint
* The server sends an 'endpoint' event containing the URI for sending messages
- *
- *
+ *
+ *
* 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.
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java
index fc31c98..7e1d259 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java
@@ -61,10 +61,13 @@ public class McpAsyncServer {
/**
* Create a new McpAsyncServer with the given transport and capabilities.
- * @param transport The transport to use for communication
- * @param tools List of tool registrations
- * @param resources Map of resource registrations
- * @param prompts Map of prompt registrations
+ * @param mcpTransport The transport layer implementation for MCP communication
+ * @param serverInfo The server implementation details
+ * @param serverCapabilities The server capabilities
+ * @param tools The list of tool registrations
+ * @param resources The map of resource registrations
+ * @param resourceTemplates The list of resource templates
+ * @param prompts The map of prompt registrations
*/
public McpAsyncServer(McpTransport mcpTransport, McpSchema.Implementation serverInfo,
McpSchema.ServerCapabilities serverCapabilities, List tools,
@@ -407,28 +410,32 @@ public class McpAsyncServer {
}
/**
- * Notify clients that the list of available tools has changed.
+ * Notifies clients that the list of available tools has changed.
+ * @return A Mono that completes when all clients have been notified
*/
public Mono notifyToolsListChanged() {
return this.mcpSession.sendNotification("notifications/tools/list_changed", null);
}
/**
- * Notify clients that the list of available resources has changed.
+ * Notifies clients that the list of available resources has changed.
+ * @return A Mono that completes when all clients have been notified
*/
public Mono notifyResourcesListChanged() {
return this.mcpSession.sendNotification("notifications/resources/list_changed", null);
}
/**
- * Notify clients that the list of available prompts has changed.
+ * Notifies clients that the list of available prompts has changed.
+ * @return A Mono that completes when all clients have been notified
*/
public Mono notifyPromptsListChanged() {
return this.mcpSession.sendNotification("notifications/prompts/list_changed", null);
}
/**
- * Close the server gracefully.
+ * Gracefully closes the server, allowing any in-progress operations to complete.
+ * @return A Mono that completes when the server has been closed
*/
public Mono closeGracefully() {
return this.mcpSession.closeGracefully();
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
index ece13f6..2c466d6 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/spec/McpSchema.java
@@ -32,7 +32,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Based on the JSON-RPC 2.0
- * specification and the and the Model
* Context Protocol Schema .
*
@@ -47,16 +47,34 @@ public class McpSchema {
// ---------------------------
// JSON-RPC Error Codes
// ---------------------------
+ /**
+ * Standard error codes used in MCP JSON-RPC responses.
+ */
public final class ErrorCodes {
+ /**
+ * Invalid JSON was received by the server.
+ */
public static final int PARSE_ERROR = -32700;
+ /**
+ * The JSON sent is not a valid Request object.
+ */
public static final int INVALID_REQUEST = -32600;
+ /**
+ * The method does not exist / is not available.
+ */
public static final int METHOD_NOT_FOUND = -32601;
+ /**
+ * Invalid method parameter(s).
+ */
public static final int INVALID_PARAMS = -32602;
+ /**
+ * Internal JSON-RPC error.
+ */
public static final int INTERNAL_ERROR = -32603;
}
@@ -340,6 +358,9 @@ public class McpSchema {
}
/**
+ * Optional annotations for the client. The client can use annotations to inform how
+ * objects are used or displayed.
+ *
* @param audience Describes who the intended customer of this object or data is. It
* can include multiple entries to indicate content useful for multiple audiences
* (e.g., `["user", "assistant"]`).
@@ -544,6 +565,10 @@ public class McpSchema {
/**
* The server's response to a prompts/list request from the client.
+ *
+ * @param prompts A list of prompts that the server provides.
+ * @param nextCursor An optional cursor for pagination. If present, indicates there
+ * are more prompts available.
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record ListPromptsResult( // @formatter:off
@@ -856,6 +881,9 @@ public class McpSchema {
* The client's response to a roots/list request from the server. This result contains
* an array of Root objects, each representing a root directory or file that the
* server can operate on.
+ *
+ * @param roots An array of Root objects, each representing a root directory or file
+ * that the server can operate on.
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record ListRootsResult( // @formatter:off
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/util/Assert.java b/mcp/src/main/java/org/springframework/ai/mcp/util/Assert.java
index 256d063..8d4ea23 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/util/Assert.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/util/Assert.java
@@ -19,10 +19,15 @@ package org.springframework.ai.mcp.util;
import reactor.util.annotation.Nullable;
/**
+ * Assertion utility class that assists in validating arguments.
+ *
* @author Christian Tzolov
*/
-public class Assert {
+/**
+ * Utility class providing assertion methods for parameter validation.
+ */
+public final class Assert {
/**
* Assert that an object is not {@code null}.
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 7f6850a..47e38c7 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
@@ -24,10 +24,12 @@ import reactor.util.annotation.Nullable;
import org.springframework.lang.Contract;
/**
+ * Miscellaneous utility methods.
+ *
* @author Christian Tzolov
*/
-public class Utils {
+public final class Utils {
/**
* Check whether the given {@code String} contains actual text .
From 9b6a0b537bc6a72b54ae1433daef70fa2e817c80 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Sun, 29 Dec 2024 21:40:51 +0100
Subject: [PATCH 013/514] refactor(mcp): improve StdioServerTransport shutdown
and error handling
- Add specific error categorization for pipe closed errors
- Enhance shutdown sequence with proper stream and scheduler cleanup
- Add debug logging for shutdown-related stream errors
- Improve isClosing state management
---
.../transport/StdioServerTransport.java | 73 ++++++++++++-------
1 file changed, 48 insertions(+), 25 deletions(-)
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
index 4d9c979..dff9727 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
@@ -171,9 +171,18 @@ public class StdioServerTransport implements McpTransport {
}
}
catch (IOException e) {
- if (!isClosing) {
+ // Check isClosing before the error occurs to properly categorize it
+ boolean wasClosing = isClosing;
+ isClosing = true;
+ if (!wasClosing && e.getMessage().equals("Pipe closed")) {
+ logger.debug("Stream closed during shutdown", e);
+ }
+ else if (!wasClosing) {
logger.error("Error reading from stdin", e);
}
+ else {
+ logger.debug("Stream error during shutdown", e);
+ }
}
finally {
isClosing = true;
@@ -209,6 +218,9 @@ public class StdioServerTransport implements McpTransport {
logger.error("Error writing message", e);
sink.error(new RuntimeException(e));
}
+ else {
+ logger.debug("Stream closed during shutdown", e);
+ }
}
}
else if (isClosing) {
@@ -236,35 +248,46 @@ public class StdioServerTransport implements McpTransport {
return Mono.fromRunnable(() -> {
isClosing = true;
logger.debug("Initiating graceful shutdown");
- })
- // .then(Mono.defer(() -> {
- // inboundSink.tryEmitComplete();
- // outboundSink.tryEmitComplete();
- // return Mono.delay(Duration.ofMillis(100));
- // }))
+ }).then(Mono.defer(() -> {
+ // First complete the sinks to stop processing
+ inboundSink.tryEmitComplete();
+ outboundSink.tryEmitComplete();
+ return Mono.delay(Duration.ofMillis(100));
+ })).then(Mono.fromRunnable(() -> {
+ try {
+ // Dispose schedulers first
+ inboundScheduler.dispose();
+ outboundScheduler.dispose();
- .then(Mono.fromRunnable(() -> {
+ // Wait for schedulers to terminate
+ if (!inboundScheduler.isDisposed()) {
+ inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ }
+ if (!outboundScheduler.isDisposed()) {
+ outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ }
+
+ // Only after schedulers are disposed, close the streams
try {
-
- inboundScheduler.dispose();
- outboundScheduler.dispose();
-
- // Wait for schedulers to terminate
- if (!inboundScheduler.isDisposed()) {
- inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ if (inputStream != System.in) {
+ inputStream.close();
}
- if (!outboundScheduler.isDisposed()) {
- outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ if (outputStream != System.out) {
+ outputStream.flush();
+ outputStream.close();
}
+ }
+ catch (IOException e) {
+ // Log but don't throw since we're shutting down
+ logger.debug("Error closing streams during shutdown", e);
+ }
- logger.info("Graceful shutdown completed");
- }
- catch (Exception e) {
- logger.error("Error during graceful shutdown", e);
- }
- }))
- .then()
- .subscribeOn(Schedulers.boundedElastic());
+ logger.info("Graceful shutdown completed");
+ }
+ catch (Exception e) {
+ logger.error("Error during graceful shutdown", e);
+ }
+ })).then().subscribeOn(Schedulers.boundedElastic());
}
@Override
From 47b09165c59874d8833271c0d82c9b42eb2f6313 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Mon, 30 Dec 2024 09:08:56 +0100
Subject: [PATCH 014/514] refactor(mcp) Increase the server stdio shutdown
timeout
---
.../ai/mcp/server/transport/SseServerTransport.java | 1 -
.../ai/mcp/server/transport/StdioServerTransport.java | 2 +-
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java
index 8125f4d..88bda2e 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/SseServerTransport.java
@@ -6,7 +6,6 @@ import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
-import java.util.stream.Collectors;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
index dff9727..93d6c23 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
@@ -252,7 +252,7 @@ public class StdioServerTransport implements McpTransport {
// First complete the sinks to stop processing
inboundSink.tryEmitComplete();
outboundSink.tryEmitComplete();
- return Mono.delay(Duration.ofMillis(100));
+ return Mono.delay(Duration.ofMillis(200));
})).then(Mono.fromRunnable(() -> {
try {
// Dispose schedulers first
From dfa7435ad888d2c6e042311975867e3426244a0a Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Mon, 30 Dec 2024 09:23:42 +0100
Subject: [PATCH 015/514] refactor(mcp) Disable stdio server sink draining on
close
---
.../transport/StdioServerTransport.java | 80 ++++++++++---------
1 file changed, 42 insertions(+), 38 deletions(-)
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
index 93d6c23..8846774 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
@@ -248,46 +248,50 @@ public class StdioServerTransport implements McpTransport {
return Mono.fromRunnable(() -> {
isClosing = true;
logger.debug("Initiating graceful shutdown");
- }).then(Mono.defer(() -> {
- // First complete the sinks to stop processing
- inboundSink.tryEmitComplete();
- outboundSink.tryEmitComplete();
- return Mono.delay(Duration.ofMillis(200));
- })).then(Mono.fromRunnable(() -> {
- try {
- // Dispose schedulers first
- inboundScheduler.dispose();
- outboundScheduler.dispose();
-
- // Wait for schedulers to terminate
- if (!inboundScheduler.isDisposed()) {
- inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
- }
- if (!outboundScheduler.isDisposed()) {
- outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
- }
-
- // Only after schedulers are disposed, close the streams
+ })
+ // .then(Mono.defer(() -> {
+ // // First complete the sinks to stop processing
+ // inboundSink.tryEmitComplete();
+ // outboundSink.tryEmitComplete();
+ // return Mono.delay(Duration.ofMillis(100));
+ // }))
+ .then(Mono.fromRunnable(() -> {
try {
- if (inputStream != System.in) {
- inputStream.close();
- }
- if (outputStream != System.out) {
- outputStream.flush();
- outputStream.close();
- }
- }
- catch (IOException e) {
- // Log but don't throw since we're shutting down
- logger.debug("Error closing streams during shutdown", e);
- }
+ // Dispose schedulers first
+ inboundScheduler.dispose();
+ outboundScheduler.dispose();
- logger.info("Graceful shutdown completed");
- }
- catch (Exception e) {
- logger.error("Error during graceful shutdown", e);
- }
- })).then().subscribeOn(Schedulers.boundedElastic());
+ // Wait for schedulers to terminate
+ if (!inboundScheduler.isDisposed()) {
+ inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ }
+ if (!outboundScheduler.isDisposed()) {
+ outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
+ }
+
+ // Only after schedulers are disposed, close the streams
+ try {
+ if (inputStream != System.in) {
+ inputStream.close();
+ }
+ if (outputStream != System.out) {
+ outputStream.flush();
+ outputStream.close();
+ }
+ }
+ catch (IOException e) {
+ // Log but don't throw since we're shutting down
+ logger.debug("Error closing streams during shutdown", e);
+ }
+
+ logger.info("Graceful shutdown completed");
+ }
+ catch (Exception e) {
+ logger.error("Error during graceful shutdown", e);
+ }
+ }))
+ .then()
+ .subscribeOn(Schedulers.boundedElastic());
}
@Override
From fcf0ff0ae246752ee7cff3524f684560ba2a7fef Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Mon, 30 Dec 2024 11:52:43 +0100
Subject: [PATCH 016/514] refactor: simplify StdioServerTransport to only use
System streams
Removes custom stream injection capability from StdioServerTransport and
simplifies it to only use System.in/out.
Updates related tests to use System streams instead of piped streams.
---
.../transport/StdioServerTransport.java | 80 ++++++-------------
.../mcp/server/StdioMcpAsyncServerTests.java | 4 +-
.../mcp/server/StdioMcpSyncServerTests.java | 51 +-----------
.../transport/StdioServerTransportTests.java | 12 +--
4 files changed, 33 insertions(+), 114 deletions(-)
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
index 8846774..31c1780 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
@@ -77,7 +77,7 @@ public class StdioServerTransport implements McpTransport {
* Creates a new StdioServerTransport with a default ObjectMapper and System streams.
*/
public StdioServerTransport() {
- this(new ObjectMapper(), System.in, System.out);
+ this(new ObjectMapper());
}
/**
@@ -86,26 +86,15 @@ public class StdioServerTransport implements McpTransport {
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
*/
public StdioServerTransport(ObjectMapper objectMapper) {
- this(objectMapper, System.in, System.out);
- }
- /**
- * Creates a new StdioServerTransport with the specified ObjectMapper and streams.
- * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
- * @param inputStream The input stream to read from
- * @param outputStream The output stream to write to
- */
- public StdioServerTransport(ObjectMapper objectMapper, InputStream inputStream, OutputStream outputStream) {
Assert.notNull(objectMapper, "The ObjectMapper can not be null");
- Assert.notNull(inputStream, "The InputStream can not be null");
- Assert.notNull(outputStream, "The OutputStream can not be null");
this.inboundSink = Sinks.many().unicast().onBackpressureBuffer();
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer();
this.objectMapper = objectMapper;
- this.inputStream = inputStream;
- this.outputStream = outputStream;
+ this.inputStream = System.in;
+ this.outputStream = System.out;
// Use bounded schedulers for better resource management
this.inboundScheduler = Schedulers.newBoundedElastic(1, 1, "inbound");
@@ -248,50 +237,31 @@ public class StdioServerTransport implements McpTransport {
return Mono.fromRunnable(() -> {
isClosing = true;
logger.debug("Initiating graceful shutdown");
- })
- // .then(Mono.defer(() -> {
- // // First complete the sinks to stop processing
- // inboundSink.tryEmitComplete();
- // outboundSink.tryEmitComplete();
- // return Mono.delay(Duration.ofMillis(100));
- // }))
- .then(Mono.fromRunnable(() -> {
- try {
- // Dispose schedulers first
- inboundScheduler.dispose();
- outboundScheduler.dispose();
+ }).then(Mono.defer(() -> {
+ // First complete the sinks to stop processing
+ inboundSink.tryEmitComplete();
+ outboundSink.tryEmitComplete();
+ return Mono.delay(Duration.ofMillis(100));
+ })).then(Mono.fromRunnable(() -> {
+ try {
+ // Dispose schedulers first
+ inboundScheduler.dispose();
+ outboundScheduler.dispose();
- // Wait for schedulers to terminate
- if (!inboundScheduler.isDisposed()) {
- inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
- }
- if (!outboundScheduler.isDisposed()) {
- outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
- }
-
- // Only after schedulers are disposed, close the streams
- try {
- if (inputStream != System.in) {
- inputStream.close();
- }
- if (outputStream != System.out) {
- outputStream.flush();
- outputStream.close();
- }
- }
- catch (IOException e) {
- // Log but don't throw since we're shutting down
- logger.debug("Error closing streams during shutdown", e);
- }
-
- logger.info("Graceful shutdown completed");
+ // Wait for schedulers to terminate
+ if (!inboundScheduler.isDisposed()) {
+ inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
}
- catch (Exception e) {
- logger.error("Error during graceful shutdown", e);
+ if (!outboundScheduler.isDisposed()) {
+ outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
}
- }))
- .then()
- .subscribeOn(Schedulers.boundedElastic());
+
+ logger.info("Graceful shutdown completed");
+ }
+ catch (Exception e) {
+ logger.error("Error during graceful shutdown", e);
+ }
+ })).then().subscribeOn(Schedulers.boundedElastic());
}
@Override
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java
index 9a0185c..9d5c80b 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java
@@ -16,8 +16,6 @@
package org.springframework.ai.mcp.server;
-import com.fasterxml.jackson.databind.ObjectMapper;
-
import org.springframework.ai.mcp.server.transport.StdioServerTransport;
import org.springframework.ai.mcp.spec.McpTransport;
@@ -30,7 +28,7 @@ class StdioMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
@Override
protected McpTransport createMcpTransport() {
- return new StdioServerTransport(new ObjectMapper());
+ return new StdioServerTransport();
}
}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java
index 745264a..a90e4de 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java
@@ -16,11 +16,6 @@
package org.springframework.ai.mcp.server;
-import java.io.PipedInputStream;
-import java.io.PipedOutputStream;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-
import org.springframework.ai.mcp.server.transport.StdioServerTransport;
import org.springframework.ai.mcp.spec.McpTransport;
@@ -31,53 +26,9 @@ import org.springframework.ai.mcp.spec.McpTransport;
*/
class StdioMcpSyncServerTests extends AbstractMcpSyncServerTests {
- private PipedInputStream testInput;
-
- private PipedOutputStream testOutput;
-
- private PipedOutputStream writeToInput;
-
- private PipedInputStream readFromOutput;
-
@Override
protected McpTransport createMcpTransport() {
- try {
- testInput = new PipedInputStream();
- writeToInput = new PipedOutputStream(testInput);
- testOutput = new PipedOutputStream();
- readFromOutput = new PipedInputStream(testOutput);
- return new StdioServerTransport(new ObjectMapper(), testInput, testOutput);
- }
- catch (Exception e) {
- throw new RuntimeException("Failed to initialize test streams", e);
- }
- }
-
- @Override
- protected void onStart() {
- // No special setup needed for stdio transport
- }
-
- @Override
- protected void onClose() {
- try {
- if (testInput != null) {
- testInput.close();
- }
- if (testOutput != null) {
- testOutput.close();
- }
- if (writeToInput != null) {
- writeToInput.close();
- }
- if (readFromOutput != null) {
- readFromOutput.close();
- }
- }
- catch (Exception e) {
- // Log but don't throw since this is cleanup
- e.printStackTrace();
- }
+ return new StdioServerTransport();
}
}
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java
index 34e549b..ba0446d 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java
@@ -92,7 +92,7 @@ class StdioServerTransportTests {
testIn = new ByteArrayInputStream((jsonMessage + "\n").getBytes(StandardCharsets.UTF_8));
// Create transport with test streams
- transport = new StdioServerTransport(objectMapper, testIn, testOutPrintStream);
+ transport = new StdioServerTransport(objectMapper);
// Parse expected message
McpSchema.JSONRPCRequest expected = objectMapper.readValue(jsonMessage, McpSchema.JSONRPCRequest.class);
@@ -109,9 +109,9 @@ class StdioServerTransportTests {
@Disabled
void shouldHandleOutgoingMessages() throws Exception {
// Create transport with test streams
- transport = new StdioServerTransport(objectMapper, new BlockingInputStream(), testOutPrintStream);
- // transport = new StdioServerTransport(objectMapper, new ByteArrayInputStream(new
- // byte[0]), testOutPrintStream);
+ transport = new StdioServerTransport(objectMapper);
+ // transport = new StdioServerTransport(objectMapper, new BlockingInputStream(),
+ // testOutPrintStream);
// Create test messages
JSONRPCRequest initMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "init", "init-id",
@@ -137,7 +137,7 @@ class StdioServerTransportTests {
@Test
void shouldWaitForProcessorsBeforeSendingMessage() {
// Create transport with test streams
- transport = new StdioServerTransport(objectMapper, new ByteArrayInputStream(new byte[0]), testOutPrintStream);
+ transport = new StdioServerTransport(objectMapper);
// Create test message
JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test", "test-id",
@@ -154,7 +154,7 @@ class StdioServerTransportTests {
@Test
void shouldCloseGracefully() {
// Create transport with test streams
- transport = new StdioServerTransport(objectMapper, new ByteArrayInputStream(new byte[0]), testOutPrintStream);
+ transport = new StdioServerTransport(objectMapper);
// Create test message
JSONRPCRequest initMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "init", "init-id",
From ad3cdd20a937b3356c49320f074991f577e25cd0 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Mon, 30 Dec 2024 12:15:39 +0100
Subject: [PATCH 017/514] fix: Improve MCP server stability and test
reliability
- Replace bounded elastic schedulers with single thread executors
- Add timeouts to MCP server tests
- Enhance error handling in StdioServerTransport
- Comment out unused PaginatedRequest unmarshalling
- Simplify shutdown logic in StdioServerTransport
---
.../ai/mcp/server/McpAsyncServer.java | 12 ++--
.../transport/StdioServerTransport.java | 61 +++++++++----------
.../server/AbstractMcpSyncServerTests.java | 1 +
.../ai/mcp/server/SseMcpAsyncServerTests.java | 3 +
.../ai/mcp/server/SseMcpSyncServerTests.java | 3 +
.../mcp/server/StdioMcpAsyncServerTests.java | 3 +
.../mcp/server/StdioMcpSyncServerTests.java | 3 +
.../transport/StdioServerTransportTests.java | 4 --
pom.xml | 1 +
9 files changed, 50 insertions(+), 41 deletions(-)
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java
index 7e1d259..e7c07f4 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpAsyncServer.java
@@ -349,9 +349,9 @@ public class McpAsyncServer {
private DefaultMcpSession.RequestHandler resourcesListRequestHandler() {
return params -> {
- McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
- new TypeReference() {
- });
+ // McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ // new TypeReference() {
+ // });
var resourceList = this.resources.values().stream().map(ResourceRegistration::resource).toList();
@@ -361,9 +361,9 @@ public class McpAsyncServer {
private DefaultMcpSession.RequestHandler resourceTemplateListRequestHandler() {
return params -> {
- McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
- new TypeReference() {
- });
+ // McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
+ // new TypeReference() {
+ // });
return Mono.just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
};
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
index 31c1780..675737f 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/transport/StdioServerTransport.java
@@ -23,6 +23,7 @@ import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.util.concurrent.Executors;
import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference;
@@ -97,8 +98,8 @@ public class StdioServerTransport implements McpTransport {
this.outputStream = System.out;
// Use bounded schedulers for better resource management
- this.inboundScheduler = Schedulers.newBoundedElastic(1, 1, "inbound");
- this.outboundScheduler = Schedulers.newBoundedElastic(1, 1, "outbound");
+ this.inboundScheduler = Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), "inbound");
+ this.outboundScheduler = Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), "outbound");
}
@Override
@@ -139,38 +140,43 @@ public class StdioServerTransport implements McpTransport {
private void startInboundProcessing() {
this.inboundScheduler.schedule(() -> {
inboundReady.tryEmitValue(null);
- try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
- String line;
- while (!isClosing && (line = reader.readLine()) != null) {
+ BufferedReader reader = null;
+ try {
+ reader = new BufferedReader(new InputStreamReader(inputStream));
+ while (!isClosing) {
try {
- JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, line);
- if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
+ String line = reader.readLine();
+ if (line == null || isClosing) {
+ break;
+ }
+
+ try {
+ JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, line);
+ if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
+ if (!isClosing) {
+ logger.error("Failed to enqueue message");
+ }
+ break;
+ }
+ }
+ catch (Exception e) {
if (!isClosing) {
- logger.error("Failed to enqueue message");
+ logger.error("Error processing inbound message", e);
}
break;
}
}
- catch (Exception e) {
+ catch (IOException e) {
if (!isClosing) {
- logger.error("Error processing inbound message", e);
+ logger.error("Error reading from stdin", e);
}
break;
}
}
}
- catch (IOException e) {
- // Check isClosing before the error occurs to properly categorize it
- boolean wasClosing = isClosing;
- isClosing = true;
- if (!wasClosing && e.getMessage().equals("Pipe closed")) {
- logger.debug("Stream closed during shutdown", e);
- }
- else if (!wasClosing) {
- logger.error("Error reading from stdin", e);
- }
- else {
- logger.debug("Stream error during shutdown", e);
+ catch (Exception e) {
+ if (!isClosing) {
+ logger.error("Error in inbound processing", e);
}
}
finally {
@@ -234,6 +240,7 @@ public class StdioServerTransport implements McpTransport {
@Override
public Mono closeGracefully() {
+
return Mono.fromRunnable(() -> {
isClosing = true;
logger.debug("Initiating graceful shutdown");
@@ -244,18 +251,10 @@ public class StdioServerTransport implements McpTransport {
return Mono.delay(Duration.ofMillis(100));
})).then(Mono.fromRunnable(() -> {
try {
- // Dispose schedulers first
+ // Dispose schedulers with longer timeout
inboundScheduler.dispose();
outboundScheduler.dispose();
- // Wait for schedulers to terminate
- if (!inboundScheduler.isDisposed()) {
- inboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
- }
- if (!outboundScheduler.isDisposed()) {
- outboundScheduler.disposeGracefully().block(Duration.ofSeconds(5));
- }
-
logger.info("Graceful shutdown completed");
}
catch (Exception e) {
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
index 15ef810..8807d40 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/AbstractMcpSyncServerTests.java
@@ -66,6 +66,7 @@ public abstract class AbstractMcpSyncServerTests {
@BeforeEach
void setUp() {
+ // onStart();
}
@AfterEach
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java
index b9a2b77..3d8336b 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpAsyncServerTests.java
@@ -17,6 +17,8 @@
package org.springframework.ai.mcp.server;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Timeout;
+
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.server.RouterFunctions;
@@ -31,6 +33,7 @@ import org.springframework.ai.mcp.spec.McpTransport;
*
* @author Christian Tzolov
*/
+@Timeout(15) // Giving extra time beyond the client timeout
class SseMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
private static final int PORT = 8181;
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java
index 2d92574..c742969 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/SseMcpSyncServerTests.java
@@ -17,6 +17,8 @@
package org.springframework.ai.mcp.server;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Timeout;
+
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import org.springframework.web.reactive.function.server.RouterFunctions;
@@ -31,6 +33,7 @@ import org.springframework.ai.mcp.spec.McpTransport;
*
* @author Christian Tzolov
*/
+@Timeout(15) // Giving extra time beyond the client timeout
class SseMcpSyncServerTests extends AbstractMcpSyncServerTests {
private static final int PORT = 8182;
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java
index 9d5c80b..8d8a31d 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpAsyncServerTests.java
@@ -16,6 +16,8 @@
package org.springframework.ai.mcp.server;
+import org.junit.jupiter.api.Timeout;
+
import org.springframework.ai.mcp.server.transport.StdioServerTransport;
import org.springframework.ai.mcp.spec.McpTransport;
@@ -24,6 +26,7 @@ import org.springframework.ai.mcp.spec.McpTransport;
*
* @author Christian Tzolov
*/
+@Timeout(15) // Giving extra time beyond the client timeout
class StdioMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
@Override
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java
index a90e4de..2e02f0d 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/StdioMcpSyncServerTests.java
@@ -16,6 +16,8 @@
package org.springframework.ai.mcp.server;
+import org.junit.jupiter.api.Timeout;
+
import org.springframework.ai.mcp.server.transport.StdioServerTransport;
import org.springframework.ai.mcp.spec.McpTransport;
@@ -24,6 +26,7 @@ import org.springframework.ai.mcp.spec.McpTransport;
*
* @author Christian Tzolov
*/
+@Timeout(15) // Giving extra time beyond the client timeout
class StdioMcpSyncServerTests extends AbstractMcpSyncServerTests {
@Override
diff --git a/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java
index ba0446d..c6ce443 100644
--- a/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java
+++ b/mcp/src/test/java/org/springframework/ai/mcp/server/transport/StdioServerTransportTests.java
@@ -16,7 +16,6 @@
package org.springframework.ai.mcp.server.transport;
-import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.PrintStream;
@@ -51,8 +50,6 @@ class StdioServerTransportTests {
private ByteArrayOutputStream testOut;
- private ByteArrayInputStream testIn;
-
private ByteArrayOutputStream testErr;
private PrintStream testOutPrintStream;
@@ -89,7 +86,6 @@ class StdioServerTransportTests {
void shouldHandleIncomingMessages() throws Exception {
// Prepare test input
String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{},\"id\":1}";
- testIn = new ByteArrayInputStream((jsonMessage + "\n").getBytes(StandardCharsets.UTF_8));
// Create transport with test streams
transport = new StdioServerTransport(objectMapper);
diff --git a/pom.xml b/pom.xml
index 4a1823b..383a2d2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -174,6 +174,7 @@
false
+
From c36d4677c164335c326c509a1275662572393271 Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Mon, 30 Dec 2024 13:13:32 +0100
Subject: [PATCH 018/514] docs(mcp) Improve McpServer and McpClient javadoc
---
mcp/README.md | 4 +-
mcp/pom.xml | 2 +-
.../ai/mcp/client/McpClient.java | 187 +++++++--
.../ai/mcp/server/McpServer.java | 384 +++++++++++++++---
4 files changed, 485 insertions(+), 92 deletions(-)
diff --git a/mcp/README.md b/mcp/README.md
index f0bf8a9..4c9c641 100644
--- a/mcp/README.md
+++ b/mcp/README.md
@@ -1,6 +1,6 @@
-# Spring AI MCP SDK
+# Java MCP SDK
-This SDK implements the Model Context Protocol, enabling seamless integration with language models and AI tools.
+Java SDK implementation of the Model Context Protocol, enabling seamless integration with language models and AI tools.
## Features
diff --git a/mcp/pom.xml b/mcp/pom.xml
index c5c2d54..6bb5e13 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -11,7 +11,7 @@
mcp
jar
Java MCP SDK
- Core Java implementation of the Model Context Protocol (MCP) specification, providing synchronous and asynchronous clients, server implementations (coming in 0.3.0), with support for tool discovery, resource management, and multiple transport implementations
+ Java SDK implementation of the Model Context Protocol, enabling seamless integration with language models and AI tools
https://github.com/spring-projects-experimental/spring-ai-mcp
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
index 3c4663a..9f49924 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
@@ -34,42 +34,90 @@ import org.springframework.ai.mcp.spec.McpTransport;
import org.springframework.ai.mcp.util.Assert;
/**
- * Factory class providing static methods for creating Model Context Protocol (MCP)
- * clients. This class serves as the main entry point for establishing connections with
- * MCP servers, offering both synchronous and asynchronous client implementations.
- *
- *
- * The class provides factory methods to create either:
+ * Factory class for creating Model Context Protocol (MCP) clients. MCP is a protocol that enables
+ * AI models to interact with external tools and resources through a standardized interface.
+ *
+ *
This class serves as the main entry point for establishing connections with MCP servers,
+ * implementing the client-side of the MCP specification. The protocol follows a client-server
+ * architecture where:
*
- * {@link McpAsyncClient} for non-blocking operations
- * {@link McpSyncClient} for blocking operations
+ * The client (this implementation) initiates connections and sends requests
+ * The server responds to requests and provides access to tools and resources
+ * Communication occurs through a transport layer (e.g., stdio, SSE) using JSON-RPC 2.0
*
*
- *
- * Use the builder pattern for flexible client configuration:
+ *
The class provides factory methods to create either:
+ *
+ * {@link McpAsyncClient} for non-blocking operations with CompletableFuture responses
+ * {@link McpSyncClient} for blocking operations with direct responses
+ *
*
+ * Example of creating a basic client:
*
{@code
* McpClient.using(transport)
- * .requestTimeout(Duration.ofSeconds(5))
- * .sync(); // or .async()
+ * .requestTimeout(Duration.ofSeconds(5))
+ * .sync(); // or .async()
* }
*
+ * Example with advanced configuration:
+ *
{@code
+ * McpClient.using(transport)
+ * .requestTimeout(Duration.ofSeconds(10))
+ * .capabilities(new ClientCapabilities(...))
+ * .clientInfo(new Implementation("My Client", "1.0.0"))
+ * .roots(new Root("file://workspace", "Workspace Files"))
+ * .toolsChangeConsumer(tools -> System.out.println("Tools updated: " + tools))
+ * .async();
+ * }
+ *
+ * The client supports:
+ *
+ * Tool discovery and invocation
+ * Resource access and management
+ * Prompt template handling
+ * Real-time updates through change consumers
+ * Custom sampling strategies
+ *
+ *
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
+ * @see McpAsyncClient
+ * @see McpSyncClient
+ * @see McpTransport
*/
public interface McpClient {
/**
- * Start building an MCP client with the specified transport.
- * @param transport The transport layer implementation for MCP communication
- * @return A new builder instance
+ * Start building an MCP client with the specified transport layer.
+ * The transport layer handles the low-level communication between
+ * client and server using protocols like stdio or Server-Sent Events (SSE).
+ *
+ * @param transport The transport layer implementation for MCP communication.
+ * Common implementations include {@code StdioClientTransport}
+ * for stdio-based communication and {@code SseClientTransport}
+ * for SSE-based communication.
+ * @return A new builder instance for configuring the client
+ * @throws IllegalArgumentException if transport is null
*/
public static Builder using(McpTransport transport) {
return new Builder(transport);
}
/**
- * Builder class for creating MCP clients with custom configuration.
+ * Builder class for creating and configuring MCP clients. This class follows
+ * the builder pattern to provide a fluent API for setting up clients with
+ * custom configurations.
+ *
+ * The builder supports configuration of:
+ *
+ * Transport layer for client-server communication
+ * Request timeouts for operation boundaries
+ * Client capabilities for feature negotiation
+ * Client implementation details for version tracking
+ * Root URIs for resource access
+ * Change notification handlers for tools, resources, and prompts
+ * Custom message sampling logic
+ *
*/
public static class Builder {
@@ -79,7 +127,7 @@ public interface McpClient {
private ClientCapabilities capabilities;
- private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.0");
+ private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1");
private Map roots = new HashMap<>();
@@ -97,9 +145,14 @@ public interface McpClient {
}
/**
- * Set the request timeout duration.
- * @param requestTimeout The duration to wait before timing out requests
- * @return This builder instance
+ * Sets the duration to wait for server responses before timing out requests.
+ * This timeout applies to all requests made through the client, including
+ * tool calls, resource access, and prompt operations.
+ *
+ * @param requestTimeout The duration to wait before timing out requests.
+ * Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if requestTimeout is null
*/
public Builder requestTimeout(Duration requestTimeout) {
Assert.notNull(requestTimeout, "Request timeout must not be null");
@@ -107,18 +160,46 @@ public interface McpClient {
return this;
}
+ /**
+ * Sets the client capabilities that will be advertised to the server during
+ * connection initialization. Capabilities define what features the client supports,
+ * such as tool execution, resource access, and prompt handling.
+ *
+ * @param capabilities The client capabilities configuration. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if capabilities is null
+ */
public Builder capabilities(ClientCapabilities capabilities) {
Assert.notNull(capabilities, "Capabilities must not be null");
this.capabilities = capabilities;
return this;
}
+ /**
+ * Sets the client implementation information that will be shared with the server
+ * during connection initialization. This helps with version compatibility and
+ * debugging.
+ *
+ * @param clientInfo The client implementation details including name and version.
+ * Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if clientInfo is null
+ */
public Builder clientInfo(Implementation clientInfo) {
Assert.notNull(clientInfo, "Client info must not be null");
this.clientInfo = clientInfo;
return this;
}
+ /**
+ * Sets the root URIs that this client can access. Roots define the base URIs
+ * for resources that the client can request from the server. For example,
+ * a root might be "file://workspace" for accessing workspace files.
+ *
+ * @param roots A list of root definitions. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if roots is null
+ */
public Builder roots(List roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
@@ -127,6 +208,15 @@ public interface McpClient {
return this;
}
+ /**
+ * Sets the root URIs that this client can access, using a varargs parameter
+ * for convenience. This is an alternative to {@link #roots(List)}.
+ *
+ * @param roots An array of root definitions. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if roots is null
+ * @see #roots(List)
+ */
public Builder roots(Root... roots) {
Assert.notNull(roots, "Roots must not be null");
for (Root root : roots) {
@@ -135,24 +225,64 @@ public interface McpClient {
return this;
}
+ /**
+ * Sets a custom sampling handler for processing message creation requests.
+ * The sampling handler can modify or validate messages before they are sent
+ * to the server, enabling custom processing logic.
+ *
+ * @param samplingHandler A function that processes message requests and returns
+ * results. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if samplingHandler is null
+ */
public Builder sampling(Function samplingHandler) {
Assert.notNull(samplingHandler, "Sampling handler must not be null");
this.samplingHandler = samplingHandler;
return this;
}
+ /**
+ * Adds a consumer to be notified when the available tools change. This allows
+ * the client to react to changes in the server's tool capabilities, such as
+ * tools being added or removed.
+ *
+ * @param toolsChangeConsumer A consumer that receives the updated list of
+ * available tools. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if toolsChangeConsumer is null
+ */
public Builder toolsChangeConsumer(Consumer> toolsChangeConsumer) {
Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null");
this.toolsChangeConsumers.add(toolsChangeConsumer);
return this;
}
+ /**
+ * Adds a consumer to be notified when the available resources change. This allows
+ * the client to react to changes in the server's resource availability, such as
+ * files being added or removed.
+ *
+ * @param resourcesChangeConsumer A consumer that receives the updated list of
+ * available resources. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if resourcesChangeConsumer is null
+ */
public Builder resourcesChangeConsumer(Consumer> resourcesChangeConsumer) {
Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null");
this.resourcesChangeConsumers.add(resourcesChangeConsumer);
return this;
}
+ /**
+ * Adds a consumer to be notified when the available prompts change. This allows
+ * the client to react to changes in the server's prompt templates, such as
+ * new templates being added or existing ones being modified.
+ *
+ * @param promptsChangeConsumer A consumer that receives the updated list of
+ * available prompts. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if promptsChangeConsumer is null
+ */
public Builder promptsChangeConsumer(Consumer> promptsChangeConsumer) {
Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null");
this.promptsChangeConsumers.add(promptsChangeConsumer);
@@ -160,16 +290,25 @@ public interface McpClient {
}
/**
- * Build a synchronous MCP client.
- * @return A new instance of {@link McpSyncClient}
+ * Builds a synchronous MCP client that provides blocking operations.
+ * Synchronous clients wait for each operation to complete before returning,
+ * making them simpler to use but potentially less performant for
+ * concurrent operations.
+ *
+ * @return A new instance of {@link McpSyncClient} configured with this
+ * builder's settings
*/
public McpSyncClient sync() {
return new McpSyncClient(async());
}
/**
- * Build an asynchronous MCP client.
- * @return A new instance of {@link McpAsyncClient}
+ * Builds an asynchronous MCP client that provides non-blocking operations.
+ * Asynchronous clients return CompletableFuture objects immediately,
+ * allowing for concurrent operations and reactive programming patterns.
+ *
+ * @return A new instance of {@link McpAsyncClient} configured with this
+ * builder's settings
*/
public McpAsyncClient async() {
return new McpAsyncClient(transport, requestTimeout, clientInfo, capabilities, roots, toolsChangeConsumers,
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
index cbabff4..4cc051c 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/server/McpServer.java
@@ -30,49 +30,162 @@ import org.springframework.ai.mcp.spec.McpTransport;
import org.springframework.ai.mcp.util.Assert;
/**
- * Factory class providing static methods for creating Model Context Protocol (MCP)
- * servers. This class serves as the main entry point for establishing MCP servers,
- * offering both synchronous and asynchronous server implementations.
+ * Factory class for creating Model Context Protocol (MCP) servers. MCP servers expose
+ * tools, resources, and prompts to AI models through a standardized interface.
*
- *
- * The class provides factory methods to create either:
+ *
This class serves as the main entry point for implementing the server-side of
+ * the MCP specification. The server's responsibilities include:
*
- * {@link McpAsyncServer} for non-blocking operations
- * {@link McpSyncServer} for blocking operations
+ * Exposing tools that models can invoke to perform actions
+ * Providing access to resources that give models context
+ * Managing prompt templates for structured model interactions
+ * Handling client connections and requests
+ * Implementing capability negotiation
*
*
- *
- * Use the builder pattern for flexible server configuration:
+ *
The class provides factory methods to create either:
+ *
+ * {@link McpAsyncServer} for non-blocking operations with CompletableFuture responses
+ * {@link McpSyncServer} for blocking operations with direct responses
+ *
*
+ * Example of creating a basic server:
*
{@code
* McpServer.using(transport)
- * .serverInfo(new McpSchema.Implementation("my-server", "1.0.0"))
- * .tool(new MyToolHandler())
- * .async(); // or .sync()
+ * .info("my-server", "1.0.0")
+ * .tool(new Tool("calculator", "Performs calculations", schema),
+ * args -> new CallToolResult("Result: " + calculate(args)))
+ * .sync(); // or .async()
+ * }
+ *
+ * Example with comprehensive configuration:
+ *
{@code
+ * McpServer.using(transport)
+ * .info("advanced-server", "2.0.0")
+ * .capabilities(new ServerCapabilities(...))
+ * // Register tools
+ * .tools(
+ * new ToolRegistration(calculatorTool, calculatorHandler),
+ * new ToolRegistration(weatherTool, weatherHandler)
+ * )
+ * // Register resources
+ * .resources(
+ * new ResourceRegistration(fileResource, fileHandler),
+ * new ResourceRegistration(dbResource, dbHandler)
+ * )
+ * // Add resource templates
+ * .resourceTemplates(
+ * new ResourceTemplate("file://{path}", "Access files"),
+ * new ResourceTemplate("db://{table}", "Access database")
+ * )
+ * // Register prompts
+ * .prompts(
+ * new PromptRegistration(analysisPrompt, analysisHandler),
+ * new PromptRegistration(summaryPrompt, summaryHandler)
+ * )
+ * .async();
* }
*
* @author Christian Tzolov
+ * @see McpAsyncServer
+ * @see McpSyncServer
+ * @see McpTransport
*/
public interface McpServer {
/**
- * Registration of a tool with its handler function. MCP allows servers to expose
- * tools that can be invoked by language models. Tools enable models to interact with
- * external systems, such as querying databases, calling APIs, or performing
- * computations. Each tool is uniquely identified by a name and includes metadata
- * describing its schema.
+ * Registration of a tool with its handler function. Tools are the primary way for
+ * MCP servers to expose functionality to AI models. Each tool represents a specific
+ * capability, such as:
+ *
+ * Performing calculations
+ * Accessing external APIs
+ * Querying databases
+ * Manipulating files
+ * Executing system commands
+ *
*
- * @param tool The tool definition
- * @param call The function to handle tool execution
+ * Example tool registration:
+ *
{@code
+ * new ToolRegistration(
+ * new Tool(
+ * "calculator",
+ * "Performs mathematical calculations",
+ * new JsonSchemaObject()
+ * .required("expression")
+ * .property("expression", JsonSchemaType.STRING)
+ * ),
+ * args -> {
+ * String expr = (String) args.get("expression");
+ * return new CallToolResult("Result: " + evaluate(expr));
+ * }
+ * )
+ * }
+ *
+ * @param tool The tool definition including name, description, and parameter schema
+ * @param call The function that implements the tool's logic, receiving arguments
+ * and returning results
*/
public static record ToolRegistration(Tool tool, Function, CallToolResult> call) {
-
}
+ /**
+ * Registration of a resource with its handler function. Resources provide context
+ * to AI models by exposing data such as:
+ *
+ * File contents
+ * Database records
+ * API responses
+ * System information
+ * Application state
+ *
+ *
+ * Example resource registration:
+ *
{@code
+ * new ResourceRegistration(
+ * new Resource("docs", "Documentation files", "text/markdown"),
+ * request -> {
+ * String content = readFile(request.getPath());
+ * return new ReadResourceResult(content);
+ * }
+ * )
+ * }
+ *
+ * @param resource The resource definition including name, description, and MIME type
+ * @param readHandler The function that handles resource read requests
+ */
public static record ResourceRegistration(McpSchema.Resource resource,
Function readHandler) {
}
+ /**
+ * Registration of a prompt template with its handler function. Prompts provide
+ * structured templates for AI model interactions, supporting:
+ *
+ * Consistent message formatting
+ * Parameter substitution
+ * Context injection
+ * Response formatting
+ * Instruction templating
+ *
+ *
+ * Example prompt registration:
+ *
{@code
+ * new PromptRegistration(
+ * new Prompt("analyze", "Code analysis template"),
+ * request -> {
+ * String code = request.getArguments().get("code");
+ * return new GetPromptResult(
+ * "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
+ * );
+ * }
+ * )
+ * }
+ *
+ * @param prompt The prompt definition including name and description
+ * @param promptHandler The function that processes prompt requests and returns
+ * formatted templates
+ */
public static record PromptRegistration(McpSchema.Prompt propmpt,
Function promptHandler) {
}
@@ -132,9 +245,14 @@ public interface McpServer {
}
/**
- * Set the server implementation information.
- * @param serverInfo The server implementation details
- * @return This builder instance
+ * Sets the server implementation information that will be shared with clients
+ * during connection initialization. This helps with version compatibility,
+ * debugging, and server identification.
+ *
+ * @param serverInfo The server implementation details including name and version.
+ * Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if serverInfo is null
*/
public Builder info(McpSchema.Implementation serverInfo) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -143,10 +261,14 @@ public interface McpServer {
}
/**
- * Set the server implementation information using name and version.
- * @param name The server name
- * @param version The server version
- * @return This builder instance
+ * Sets the server implementation information using name and version strings.
+ * This is a convenience method alternative to {@link #info(McpSchema.Implementation)}.
+ *
+ * @param name The server name. Must not be null or empty.
+ * @param version The server version. Must not be null or empty.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if name or version is null or empty
+ * @see #info(McpSchema.Implementation)
*/
public Builder info(String name, String version) {
Assert.hasText(name, "Name must not be null or empty");
@@ -156,9 +278,20 @@ public interface McpServer {
}
/**
- * Set the server capabilities.
- * @param serverCapabilities The server capabilities configuration
- * @return This builder instance
+ * Sets the server capabilities that will be advertised to clients during
+ * connection initialization. Capabilities define what features the server
+ * supports, such as:
+ *
+ * Tool execution
+ * Resource access
+ * Prompt handling
+ * Streaming responses
+ * Batch operations
+ *
+ *
+ * @param serverCapabilities The server capabilities configuration. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if serverCapabilities is null
*/
public Builder capabilities(McpSchema.ServerCapabilities serverCapabilities) {
this.serverCapabilities = serverCapabilities;
@@ -166,10 +299,23 @@ public interface McpServer {
}
/**
- * Add a tool with its handler function.
- * @param tool The tool definition
- * @param handler The function to handle tool execution
- * @return This builder instance
+ * Adds a single tool with its implementation handler to the server. This is
+ * a convenience method for registering individual tools without creating a
+ * {@link ToolRegistration} explicitly.
+ *
+ * Example usage:
+ *
{@code
+ * .tool(
+ * new Tool("calculator", "Performs calculations", schema),
+ * args -> new CallToolResult("Result: " + calculate(args))
+ * )
+ * }
+ *
+ * @param tool The tool definition including name, description, and schema.
+ * Must not be null.
+ * @param handler The function that implements the tool's logic. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if tool or handler is null
*/
public Builder tool(McpSchema.Tool tool, Function, McpSchema.CallToolResult> handler) {
Assert.notNull(tool, "Tool must not be null");
@@ -181,9 +327,15 @@ public interface McpServer {
}
/**
- * Add multiple tool handlers to the server.
- * @param toolRegistrations The list of tool handlers to add
- * @return This builder instance
+ * Adds multiple tools with their handlers to the server using a List.
+ * This method is useful when tools are dynamically generated or loaded
+ * from a configuration source.
+ *
+ * @param toolRegistrations The list of tool registrations to add.
+ * Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if toolRegistrations is null
+ * @see #tools(ToolRegistration...)
*/
public Builder tools(List toolRegistrations) {
Assert.notNull(toolRegistrations, "Tool handlers list must not be null");
@@ -192,9 +344,22 @@ public interface McpServer {
}
/**
- * Register tools with the server.
- * @param toolRegistrations The map of tools to register
- * @return This builder instance
+ * Adds multiple tools with their handlers to the server using varargs.
+ * This method provides a convenient way to register multiple tools inline.
+ *
+ * Example usage:
+ *
{@code
+ * .tools(
+ * new ToolRegistration(calculatorTool, calculatorHandler),
+ * new ToolRegistration(weatherTool, weatherHandler),
+ * new ToolRegistration(fileManagerTool, fileManagerHandler)
+ * )
+ * }
+ *
+ * @param toolRegistrations The tool registrations to add. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if toolRegistrations is null
+ * @see #tools(List)
*/
public Builder tools(ToolRegistration... toolRegistrations) {
for (ToolRegistration tool : toolRegistrations) {
@@ -204,9 +369,15 @@ public interface McpServer {
}
/**
- * Register resources with the server.
- * @param resourceRegsitrations The map of resources to register
- * @return This builder instance
+ * Registers multiple resources with their handlers using a Map.
+ * This method is useful when resources are dynamically generated
+ * or loaded from a configuration source.
+ *
+ * @param resourceRegsitrations Map of resource name to registration.
+ * Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if resourceRegsitrations is null
+ * @see #resources(ResourceRegistration...)
*/
public Builder resources(Map resourceRegsitrations) {
Assert.notNull(resourceRegsitrations, "Resource handlers map must not be null");
@@ -215,9 +386,15 @@ public interface McpServer {
}
/**
- * Register resources with the server.
- * @param resourceRegsitrations The list of resources to register
- * @return This builder instance
+ * Registers multiple resources with their handlers using a List.
+ * This method is useful when resources need to be added in bulk
+ * from a collection.
+ *
+ * @param resourceRegsitrations List of resource registrations.
+ * Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if resourceRegsitrations is null
+ * @see #resources(ResourceRegistration...)
*/
public Builder resources(List resourceRegsitrations) {
Assert.notNull(resourceRegsitrations, "Resource handlers list must not be null");
@@ -228,9 +405,23 @@ public interface McpServer {
}
/**
- * Register resources with the server.
- * @param resourceRegistrations The list of resources to register
- * @return This builder instance
+ * Registers multiple resources with their handlers using varargs.
+ * This method provides a convenient way to register multiple
+ * resources inline.
+ *
+ * Example usage:
+ *
{@code
+ * .resources(
+ * new ResourceRegistration(fileResource, fileHandler),
+ * new ResourceRegistration(dbResource, dbHandler),
+ * new ResourceRegistration(apiResource, apiHandler)
+ * )
+ * }
+ *
+ * @param resourceRegistrations The resource registrations to add.
+ * Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if resourceRegistrations is null
*/
public Builder resources(ResourceRegistration... resourceRegistrations) {
Assert.notNull(resourceRegistrations, "Resource handlers list must not be null");
@@ -241,15 +432,36 @@ public interface McpServer {
}
/**
- * Set the resource templates.
- * @param resourceTemplates The list of resource templates
- * @return This builder instance
+ * Sets the resource templates that define patterns for dynamic resource
+ * access. Templates use URI patterns with placeholders that can be
+ * filled at runtime.
+ *
+ * Example usage:
+ *
{@code
+ * .resourceTemplates(
+ * new ResourceTemplate("file://{path}", "Access files by path"),
+ * new ResourceTemplate("db://{table}/{id}", "Access database records")
+ * )
+ * }
+ *
+ * @param resourceTemplates List of resource templates. If null, clears
+ * existing templates.
+ * @return This builder instance for method chaining
+ * @see #resourceTemplates(ResourceTemplate...)
*/
public Builder resourceTemplates(List resourceTemplates) {
this.resourceTemplates = resourceTemplates;
return this;
}
+ /**
+ * Sets the resource templates using varargs for convenience.
+ * This is an alternative to {@link #resourceTemplates(List)}.
+ *
+ * @param resourceTemplates The resource templates to set.
+ * @return This builder instance for method chaining
+ * @see #resourceTemplates(List)
+ */
public Builder resourceTemplates(ResourceTemplate... resourceTemplates) {
for (ResourceTemplate resourceTemplate : resourceTemplates) {
this.resourceTemplates.add(resourceTemplate);
@@ -258,9 +470,23 @@ public interface McpServer {
}
/**
- * Register prompts with the server.
- * @param prompts The map of prompts to register
- * @return This builder instance
+ * Registers multiple prompts with their handlers using a Map.
+ * This method is useful when prompts are dynamically generated
+ * or loaded from a configuration source.
+ *
+ * Example usage:
+ *
{@code
+ * Map prompts = new HashMap<>();
+ * prompts.put("analysis", new PromptRegistration(
+ * new Prompt("analysis", "Code analysis template"),
+ * request -> new GetPromptResult(generateAnalysisPrompt(request))
+ * ));
+ * .prompts(prompts)
+ * }
+ *
+ * @param prompts Map of prompt name to registration. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if prompts is null
*/
public Builder prompts(Map prompts) {
this.prompts.putAll(prompts);
@@ -268,9 +494,14 @@ public interface McpServer {
}
/**
- * Register prompts with the server.
- * @param prompts The list of prompts to register
- * @return This builder instance.
+ * Registers multiple prompts with their handlers using a List.
+ * This method is useful when prompts need to be added in bulk
+ * from a collection.
+ *
+ * @param prompts List of prompt registrations. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if prompts is null
+ * @see #prompts(PromptRegistration...)
*/
public Builder prompts(List prompts) {
for (PromptRegistration prompt : prompts) {
@@ -280,9 +511,22 @@ public interface McpServer {
}
/**
- * Register prompts with the server.
- * @param prompts The list of prompts to register
- * @return This builder instance
+ * Registers multiple prompts with their handlers using varargs.
+ * This method provides a convenient way to register multiple
+ * prompts inline.
+ *
+ * Example usage:
+ *
{@code
+ * .prompts(
+ * new PromptRegistration(analysisPrompt, analysisHandler),
+ * new PromptRegistration(summaryPrompt, summaryHandler),
+ * new PromptRegistration(reviewPrompt, reviewHandler)
+ * )
+ * }
+ *
+ * @param prompts The prompt registrations to add. Must not be null.
+ * @return This builder instance for method chaining
+ * @throws IllegalArgumentException if prompts is null
*/
public Builder prompts(PromptRegistration... prompts) {
for (PromptRegistration prompt : prompts) {
@@ -292,16 +536,26 @@ public interface McpServer {
}
/**
- * Build a synchronous MCP server.
- * @return A new instance of {@link McpSyncServer}
+ * Builds a synchronous MCP server that provides blocking operations.
+ * Synchronous servers process each request to completion before handling
+ * the next one, making them simpler to implement but potentially less
+ * performant for concurrent operations.
+ *
+ * @return A new instance of {@link McpSyncServer} configured with this
+ * builder's settings
*/
public McpSyncServer sync() {
return new McpSyncServer(async());
}
/**
- * Build an asynchronous MCP server.
- * @return A new instance of {@link McpAsyncServer}
+ * Builds an asynchronous MCP server that provides non-blocking operations.
+ * Asynchronous servers can handle multiple requests concurrently using
+ * CompletableFuture, making them more efficient for high-concurrency
+ * scenarios but more complex to implement.
+ *
+ * @return A new instance of {@link McpAsyncServer} configured with this
+ * builder's settings
*/
public McpAsyncServer async() {
if (serverInfo == null) {
From b6c9973cbf6d9797d34afbd25f7d745c1ea4008e Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Mon, 30 Dec 2024 13:16:08 +0100
Subject: [PATCH 019/514] Fix code formatting
---
.../ai/mcp/client/McpClient.java | 134 ++++++-------
.../ai/mcp/server/McpServer.java | 189 ++++++++----------
2 files changed, 148 insertions(+), 175 deletions(-)
diff --git a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
index 9f49924..bea9478 100644
--- a/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
+++ b/mcp/src/main/java/org/springframework/ai/mcp/client/McpClient.java
@@ -34,33 +34,37 @@ import org.springframework.ai.mcp.spec.McpTransport;
import org.springframework.ai.mcp.util.Assert;
/**
- * Factory class for creating Model Context Protocol (MCP) clients. MCP is a protocol that enables
- * AI models to interact with external tools and resources through a standardized interface.
- *
- * This class serves as the main entry point for establishing connections with MCP servers,
- * implementing the client-side of the MCP specification. The protocol follows a client-server
- * architecture where:
+ * Factory class for creating Model Context Protocol (MCP) clients. MCP is a protocol that
+ * enables AI models to interact with external tools and resources through a standardized
+ * interface.
+ *
+ *
+ * This class serves as the main entry point for establishing connections with MCP
+ * servers, implementing the client-side of the MCP specification. The protocol follows a
+ * client-server architecture where:
*
* The client (this implementation) initiates connections and sends requests
* The server responds to requests and provides access to tools and resources
- * Communication occurs through a transport layer (e.g., stdio, SSE) using JSON-RPC 2.0
+ * Communication occurs through a transport layer (e.g., stdio, SSE) using JSON-RPC
+ * 2.0
*
*
- * The class provides factory methods to create either:
+ *
+ * The class provides factory methods to create either:
*
* {@link McpAsyncClient} for non-blocking operations with CompletableFuture responses
* {@link McpSyncClient} for blocking operations with direct responses
*
*
- * Example of creating a basic client:
- *
{@code
+ *
+ * Example of creating a basic client:
{@code
* McpClient.using(transport)
* .requestTimeout(Duration.ofSeconds(5))
* .sync(); // or .async()
* }
*
- * Example with advanced configuration:
- *
{@code
+ *
+ * Example with advanced configuration:
{@code
* McpClient.using(transport)
* .requestTimeout(Duration.ofSeconds(10))
* .capabilities(new ClientCapabilities(...))
@@ -70,7 +74,8 @@ import org.springframework.ai.mcp.util.Assert;
* .async();
* }
*
- * The client supports:
+ *
+ * The client supports:
*