From abd0facf4e67fe028e02572495afe72183a64cbb Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Thu, 19 Dec 2024 19:38:38 +0100
Subject: [PATCH] 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
```