Move mcp-spring-webflux and mcp-spring-webmvc to Spring AI 2.0 (#805)
The Spring-specific transport modules (mcp-spring-webflux, mcp-spring-webmvc) have been moved out of the MCP Java SDK into the Spring AI project (org.springframework.ai group), effective with MCP Java SDK 1.0 and Spring AI 2.0. This is a breaking change. - Delete mcp-spring-webflux and mcp-spring-webmvc source modules - Remove both artifacts from mcp-bom and root pom.xml module list - Update docs and README - Add blog post explaining the breaking change with before/after migration examples Resolves: #127 Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
parent
86cde46e6d
commit
77bc64abb8
58 changed files with 229 additions and 9273 deletions
23
README.md
23
README.md
|
|
@ -20,9 +20,9 @@ For comprehensive guides and SDK API documentation
|
|||
- [Java MCP Server](https://modelcontextprotocol.github.io/java-sdk/server/) - Learn how to implement and configure a MCP servers.
|
||||
|
||||
#### Spring AI MCP documentation
|
||||
[Spring AI MCP](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html) extends the MCP Java SDK with Spring Boot integration, providing both [client](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html) and [server](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html) starters.
|
||||
The [MCP Annotations](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-annotations-overview.html) - provides annotation-based method handling for MCP servers and clients in Java.
|
||||
The [MCP Security](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-security.html) - provides comprehensive OAuth 2.0 and API key-based security support for Model Context Protocol implementations in Spring AI.
|
||||
[Spring AI MCP](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) extends the MCP Java SDK with Spring Boot integration, providing both [client](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-client-boot-starter-docs.html) and [server](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-server-boot-starter-docs.html) starters.
|
||||
The [MCP Annotations](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-annotations-overview.html) - provides annotation-based method handling for MCP servers and clients in Java.
|
||||
The [MCP Security](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-security.html) - provides comprehensive OAuth 2.0 and API key-based security support for Model Context Protocol implementations in Spring AI.
|
||||
Bootstrap your AI applications with MCP support using [Spring Initializer](https://start.spring.io).
|
||||
|
||||
## Development
|
||||
|
|
@ -139,21 +139,21 @@ MCP supports both clients (applications consuming MCP servers) and servers (appl
|
|||
|
||||
#### Client Transport in the SDK
|
||||
|
||||
* **SDK Choice**: JDK HttpClient (Java 11+) as the default client, with optional Spring WebClient support
|
||||
* **SDK Choice**: JDK HttpClient (Java 11+) as the default client
|
||||
|
||||
* **Why**: The JDK HttpClient is built-in, portable, and supports streaming responses. This keeps the default lightweight with no extra dependencies. Spring WebClient support is available for Spring-based projects.
|
||||
* **Why**: The JDK HttpClient is built-in, portable, and supports streaming responses. This keeps the default lightweight with no extra dependencies.
|
||||
|
||||
* **How we expose it**: MCP Client APIs are transport-agnostic. The core module ships with JDK HttpClient transport. A Spring module provides WebClient integration.
|
||||
* **How we expose it**: MCP Client APIs are transport-agnostic. The core module ships with JDK HttpClient transport. Spring WebClient-based transport is available in [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+.
|
||||
|
||||
* **How it fits the SDK**: This ensures all applications can talk to MCP servers out of the box, while allowing richer integration in Spring and other environments.
|
||||
|
||||
#### Server Transport in the SDK
|
||||
|
||||
* **SDK Choice**: Jakarta Servlet implementation in core, with optional Spring WebFlux and Spring WebMVC providers
|
||||
* **SDK Choice**: Jakarta Servlet implementation in core
|
||||
|
||||
* **Why**: Servlet is the most widely deployed Java server API. WebFlux and WebMVC cover a significant part of the Spring community. Together these provide reach across blocking and non-blocking models.
|
||||
* **Why**: Servlet is the most widely deployed Java server API, providing broad reach across blocking and non-blocking models without additional dependencies.
|
||||
|
||||
* **How we expose it**: Server APIs are transport-agnostic. Core includes Servlet support. Spring modules extend support for WebFlux and WebMVC.
|
||||
* **How we expose it**: Server APIs are transport-agnostic. Core includes Servlet support. Spring WebFlux and WebMVC server transports are available in [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+.
|
||||
|
||||
* **How it fits the SDK**: This allows developers to expose MCP servers in the most common Java environments today, while enabling other transport implementations such as Netty, Vert.x, or Helidon.
|
||||
|
||||
|
|
@ -176,9 +176,10 @@ The SDK is organized into modules to separate concerns and allow adopters to bri
|
|||
* `mcp-json-jackson3` – Jackson 3 implementation of JSON binding
|
||||
* `mcp` – Convenience bundle (core + Jackson 3)
|
||||
* `mcp-test` – Shared testing utilities
|
||||
* `mcp-spring` – Spring integrations (WebClient, WebFlux, WebMVC)
|
||||
|
||||
For example, a minimal adopter may depend only on `mcp` (core + Jackson), while a Spring-based application can use `mcp-spring` for deeper framework integration.
|
||||
Spring integrations (WebClient, WebFlux, WebMVC) are now part of [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`).
|
||||
|
||||
For example, a minimal adopter may depend only on `mcp` (core + Jackson), while a Spring-based application can use the Spring AI `mcp-spring-webflux` or `mcp-spring-webmvc` artifacts for deeper framework integration.
|
||||
|
||||
Additionally, `mcp-test` contains integration tests for `mcp-core`.
|
||||
`mcp-core` needs a JSON implementation to run full integration tests.
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
# Blog
|
||||
# News
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ The MCP Client is a key component in the Model Context Protocol (MCP) architectu
|
|||
!!! tip
|
||||
The core `io.modelcontextprotocol.sdk:mcp` module provides STDIO, SSE, and Streamable HTTP client transport implementations without requiring external web frameworks.
|
||||
|
||||
Spring-specific transport implementations are available as an **optional** dependency `io.modelcontextprotocol.sdk:mcp-spring-webflux` for [Spring Framework](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html) users.
|
||||
The Spring-specific WebFlux transport (`mcp-spring-webflux`) is now part of [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`) and is no longer shipped by this SDK.
|
||||
See the [MCP Client Boot Starter](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-client-boot-starter-docs.html) documentation for Spring-based client setup.
|
||||
|
||||
The client provides both synchronous and asynchronous APIs for flexibility in different application contexts.
|
||||
|
||||
|
|
@ -135,26 +136,20 @@ The client provides both synchronous and asynchronous APIs for flexibility in di
|
|||
|
||||
The transport layer handles the communication between MCP clients and servers, providing different implementations for various use cases. The client transport manages message serialization, connection establishment, and protocol-specific communication patterns.
|
||||
|
||||
=== "STDIO"
|
||||
### STDIO
|
||||
|
||||
Creates transport for process-based communication using stdin/stdout:
|
||||
Creates transport for process-based communication using stdin/stdout:
|
||||
|
||||
```java
|
||||
ServerParameters params = ServerParameters.builder("npx")
|
||||
.args("-y", "@modelcontextprotocol/server-everything", "dir")
|
||||
.build();
|
||||
McpTransport transport = new StdioClientTransport(params);
|
||||
```
|
||||
```java
|
||||
ServerParameters params = ServerParameters.builder("npx")
|
||||
.args("-y", "@modelcontextprotocol/server-everything", "dir")
|
||||
.build();
|
||||
McpTransport transport = new StdioClientTransport(params);
|
||||
```
|
||||
|
||||
=== "SSE (HttpClient)"
|
||||
### Streamable HTTP
|
||||
|
||||
Creates a framework-agnostic (pure Java API) SSE client transport. Included in the core `mcp` module:
|
||||
|
||||
```java
|
||||
McpTransport transport = new HttpClientSseClientTransport("http://your-mcp-server");
|
||||
```
|
||||
|
||||
=== "Streamable HTTP"
|
||||
=== "Streamable HttpClient"
|
||||
|
||||
Creates a Streamable HTTP client transport for efficient bidirectional communication. Included in the core `mcp` module:
|
||||
|
||||
|
|
@ -172,9 +167,28 @@ The transport layer handles the communication between MCP clients and servers, p
|
|||
- Custom HTTP request customization
|
||||
- Multiple protocol version negotiation
|
||||
|
||||
=== "SSE (WebFlux)"
|
||||
=== "Streamable WebClient (external)"
|
||||
|
||||
Creates WebFlux-based SSE client transport. Requires the `mcp-spring-webflux` dependency:
|
||||
Creates Streamable HTTP WebClient-based client transport. Requires the `mcp-spring-webflux` dependency from [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`):
|
||||
|
||||
```java
|
||||
McpTransport transport = WebFluxSseClientTransport
|
||||
.builder(WebClient.builder().baseUrl("http://your-mcp-server"))
|
||||
.build();
|
||||
```
|
||||
|
||||
### SSE HTTP (Legacy)
|
||||
|
||||
=== "SSE HttpClient"
|
||||
|
||||
Creates a framework-agnostic (pure Java API) SSE client transport. Included in the core `mcp` module:
|
||||
|
||||
```java
|
||||
McpTransport transport = new HttpClientSseClientTransport("http://your-mcp-server");
|
||||
```
|
||||
=== "SSE WebClient (external)"
|
||||
|
||||
Creates WebFlux-based SSE client transport. Requires the `mcp-spring-webflux` dependency from [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`):
|
||||
|
||||
```java
|
||||
WebClient.Builder webClientBuilder = WebClient.builder()
|
||||
|
|
@ -182,6 +196,7 @@ The transport layer handles the communication between MCP clients and servers, p
|
|||
McpTransport transport = new WebFluxSseClientTransport(webClientBuilder);
|
||||
```
|
||||
|
||||
|
||||
## Client Capabilities
|
||||
|
||||
The client can be configured with various capabilities:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: Overview
|
||||
title: Index
|
||||
description: Introduction to the Model Context Protocol (MCP) Java SDK
|
||||
---
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ enables standardized integration between AI models and tools.
|
|||
- Java HttpClient-based SSE client transport for HTTP SSE Client-side streaming
|
||||
- Servlet-based SSE server transport for HTTP SSE Server streaming
|
||||
- [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) transport for efficient bidirectional communication (client and server)
|
||||
- Optional Spring-based transports (convenience if using Spring Framework):
|
||||
- Optional Spring-based transports (available in [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+, no longer part of this SDK):
|
||||
- WebFlux SSE client and server transports for reactive HTTP streaming
|
||||
- WebFlux Streamable HTTP server transport
|
||||
- WebMVC SSE server transport for servlet-based HTTP streaming
|
||||
|
|
@ -41,56 +41,9 @@ enables standardized integration between AI models and tools.
|
|||
!!! tip
|
||||
The core `io.modelcontextprotocol.sdk:mcp` module provides default STDIO, SSE, and Streamable HTTP client and server transport implementations without requiring external web frameworks.
|
||||
|
||||
Spring-specific transports are available as optional dependencies for convenience when using the [MCP Client Boot Starter](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html) and [MCP Server Boot Starter](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html).
|
||||
Also consider the [MCP Annotations](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-annotations-overview.html) and [MCP Security](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-security.html).
|
||||
|
||||
## Architecture
|
||||
|
||||
The SDK follows a layered architecture with clear separation of concerns:
|
||||
|
||||

|
||||
|
||||
- **Client/Server Layer (McpClient/McpServer)**: Both use McpSession for sync/async operations,
|
||||
with McpClient handling client-side protocol operations and McpServer managing server-side protocol operations.
|
||||
- **Session Layer (McpSession)**: Manages communication patterns and state.
|
||||
- **Transport Layer (McpTransport)**: Handles JSON-RPC message serialization/deserialization via:
|
||||
- StdioTransport (stdin/stdout) in the core module
|
||||
- HTTP SSE transports in dedicated transport modules (Java HttpClient, Spring WebFlux, Spring WebMVC)
|
||||
- Streamable HTTP transports for efficient bidirectional communication
|
||||
|
||||
The MCP Client is a key component in the Model Context Protocol (MCP) architecture, responsible for establishing and managing connections with MCP servers.
|
||||
It implements the client-side of the protocol.
|
||||
|
||||

|
||||
|
||||
The MCP Server is a foundational component in the Model Context Protocol (MCP) architecture that provides tools, resources, and capabilities to clients.
|
||||
It implements the server-side of the protocol.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
## Module Structure
|
||||
|
||||
The SDK is organized into modules to separate concerns and allow adopters to bring in only what they need:
|
||||
|
||||
| Module | Artifact ID | Purpose |
|
||||
|--------|------------|---------|
|
||||
| `mcp-bom` | `mcp-bom` | Bill of Materials for dependency management |
|
||||
| `mcp-core` | `mcp-core` | Core reference implementation (STDIO, JDK HttpClient, Servlet, Streamable HTTP) |
|
||||
| `mcp-json-jackson2` | `mcp-json-jackson2` | Jackson 2.x JSON serialization implementation |
|
||||
| `mcp-json-jackson3` | `mcp-json-jackson3` | Jackson 3.x JSON serialization implementation |
|
||||
| `mcp` | `mcp` | Convenience bundle (`mcp-core` + `mcp-json-jackson3`) |
|
||||
| `mcp-test` | `mcp-test` | Shared testing utilities and integration tests |
|
||||
| `mcp-spring-webflux` | `mcp-spring-webflux` | Spring WebFlux integration (SSE and Streamable HTTP) |
|
||||
| `mcp-spring-webmvc` | `mcp-spring-webmvc` | Spring WebMVC integration (SSE and Streamable HTTP) |
|
||||
|
||||
!!! tip
|
||||
A minimal adopter may depend only on `mcp` (core + Jackson 3), while a Spring-based application can add `mcp-spring-webflux` or `mcp-spring-webmvc` for deeper framework integration.
|
||||
Spring-specific transports (WebFlux, WebMVC) are now part of [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ and are no longer shipped by this SDK.
|
||||
Use the [MCP Client Boot Starter](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-client-boot-starter-docs.html) and [MCP Server Boot Starter](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-server-boot-starter-docs.html) from Spring AI.
|
||||
Also consider the [MCP Annotations](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-annotations-overview.html) and [MCP Security](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-security.html).
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
|
|
|||
93
docs/overview.md
Normal file
93
docs/overview.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
title: Overview
|
||||
description: Introduction to the Model Context Protocol (MCP) Java SDK
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
## Architecture
|
||||
|
||||
The SDK follows a layered architecture with clear separation of concerns:
|
||||
|
||||

|
||||
|
||||
- **Client/Server Layer (McpClient/McpServer)**: Both use McpSession for sync/async operations,
|
||||
with McpClient handling client-side protocol operations and McpServer managing server-side protocol operations.
|
||||
- **Session Layer (McpSession)**: Manages communication patterns and state.
|
||||
- **Transport Layer (McpTransport)**: Handles JSON-RPC message serialization/deserialization via:
|
||||
- StdioTransport (stdin/stdout) in the core module
|
||||
- HTTP SSE transports in dedicated transport modules (Java HttpClient, Servlet)
|
||||
- Streamable HTTP transports for efficient bidirectional communication
|
||||
- Spring WebFlux and Spring WebMVC transports (available in [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+)
|
||||
|
||||
The MCP Client is a key component in the Model Context Protocol (MCP) architecture, responsible for establishing and managing connections with MCP servers.
|
||||
It implements the client-side of the protocol.
|
||||
|
||||

|
||||
|
||||
The MCP Server is a foundational component in the Model Context Protocol (MCP) architecture that provides tools, resources, and capabilities to clients.
|
||||
It implements the server-side of the protocol.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
## Module Structure
|
||||
|
||||
The SDK is organized into modules to separate concerns and allow adopters to bring in only what they need:
|
||||
|
||||
| Module | Artifact ID | Group | Purpose |
|
||||
|--------|------------|-------|---------|
|
||||
| `mcp-bom` | `mcp-bom` | `io.modelcontextprotocol.sdk` | Bill of Materials for dependency management |
|
||||
| `mcp-core` | `mcp-core` | `io.modelcontextprotocol.sdk` | Core reference implementation (STDIO, JDK HttpClient, Servlet, Streamable HTTP) |
|
||||
| `mcp-json-jackson2` | `mcp-json-jackson2` | `io.modelcontextprotocol.sdk` | Jackson 2.x JSON serialization implementation |
|
||||
| `mcp-json-jackson3` | `mcp-json-jackson3` | `io.modelcontextprotocol.sdk` | Jackson 3.x JSON serialization implementation |
|
||||
| `mcp` | `mcp` | `io.modelcontextprotocol.sdk` | Convenience bundle (`mcp-core` + `mcp-json-jackson3`) |
|
||||
| `mcp-test` | `mcp-test` | `io.modelcontextprotocol.sdk` | Shared testing utilities and integration tests |
|
||||
| `mcp-spring-webflux` _(external)_ | `mcp-spring-webflux` | `org.springframework.ai` | Spring WebFlux integration — part of [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ |
|
||||
| `mcp-spring-webmvc` _(external)_ | `mcp-spring-webmvc` | `org.springframework.ai` | Spring WebMVC integration — part of [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ |
|
||||
|
||||
!!! tip
|
||||
A minimal adopter may depend only on `mcp` (core + Jackson 3). Spring-based applications should use the `mcp-spring-webflux` or `mcp-spring-webmvc` artifacts from [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`), no longer part of this SDK.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :rocket:{ .lg .middle } **Quickstart**
|
||||
|
||||
---
|
||||
|
||||
Get started with dependencies and BOM configuration.
|
||||
|
||||
[:octicons-arrow-right-24: Quickstart](quickstart.md)
|
||||
|
||||
- :material-monitor:{ .lg .middle } **MCP Client**
|
||||
|
||||
---
|
||||
|
||||
Learn how to create and configure MCP clients.
|
||||
|
||||
[:octicons-arrow-right-24: Client](client.md)
|
||||
|
||||
- :material-server:{ .lg .middle } **MCP Server**
|
||||
|
||||
---
|
||||
|
||||
Learn how to implement and configure MCP servers.
|
||||
|
||||
[:octicons-arrow-right-24: Server](server.md)
|
||||
|
||||
- :fontawesome-brands-github:{ .lg .middle } **GitHub**
|
||||
|
||||
---
|
||||
|
||||
View the source code and contribute.
|
||||
|
||||
[:octicons-arrow-right-24: Repository](https://github.com/modelcontextprotocol/java-sdk)
|
||||
|
||||
</div>
|
||||
|
|
@ -44,22 +44,25 @@ Add the following dependency to your project:
|
|||
</dependency>
|
||||
```
|
||||
|
||||
If you're using the Spring Framework and want Spring-specific transport implementations, add one of the following optional dependencies:
|
||||
If you're using Spring Framework, the Spring-specific transport implementations are now part of [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`):
|
||||
|
||||
```xml
|
||||
<!-- Optional: Spring WebFlux-based SSE and Streamable HTTP client and server transport -->
|
||||
<!-- Optional: Spring WebFlux-based SSE and Streamable HTTP client and server transport (Spring AI 2.0+) -->
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>mcp-spring-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: Spring WebMVC-based SSE and Streamable HTTP server transport -->
|
||||
<!-- Optional: Spring WebMVC-based SSE and Streamable HTTP server transport (Spring AI 2.0+) -->
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>mcp-spring-webmvc</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
!!! note
|
||||
When using the `spring-ai-bom` or Spring AI starter dependencies (`spring-ai-starter-mcp-server-webflux`, `spring-ai-starter-mcp-server-webmvc`, `spring-ai-starter-mcp-client-webflux`) no explicit version is needed — the BOM manages it automatically.
|
||||
|
||||
=== "Gradle"
|
||||
|
||||
The convenience `mcp` module bundles `mcp-core` with Jackson 3.x JSON serialization:
|
||||
|
|
@ -89,17 +92,17 @@ Add the following dependency to your project:
|
|||
}
|
||||
```
|
||||
|
||||
If you're using the Spring Framework and want Spring-specific transport implementations, add one of the following optional dependencies:
|
||||
If you're using Spring Framework, the Spring-specific transport implementations are now part of [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`):
|
||||
|
||||
```groovy
|
||||
// Optional: Spring WebFlux-based SSE and Streamable HTTP client and server transport
|
||||
// Optional: Spring WebFlux-based SSE and Streamable HTTP client and server transport (Spring AI 2.0+)
|
||||
dependencies {
|
||||
implementation "io.modelcontextprotocol.sdk:mcp-spring-webflux"
|
||||
implementation "org.springframework.ai:mcp-spring-webflux"
|
||||
}
|
||||
|
||||
// Optional: Spring WebMVC-based SSE and Streamable HTTP server transport
|
||||
// Optional: Spring WebMVC-based SSE and Streamable HTTP server transport (Spring AI 2.0+)
|
||||
dependencies {
|
||||
implementation "io.modelcontextprotocol.sdk:mcp-spring-webmvc"
|
||||
implementation "org.springframework.ai:mcp-spring-webmvc"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -153,8 +156,8 @@ The following dependencies are available and managed by the BOM:
|
|||
- **JSON Serialization**
|
||||
- `io.modelcontextprotocol.sdk:mcp-json-jackson3` - Jackson 3.x JSON serialization implementation (included in `mcp` bundle).
|
||||
- `io.modelcontextprotocol.sdk:mcp-json-jackson2` - Jackson 2.x JSON serialization implementation for projects that require Jackson 2.x compatibility.
|
||||
- **Optional Transport Dependencies** (convenience if using Spring Framework)
|
||||
- `io.modelcontextprotocol.sdk:mcp-spring-webflux` - WebFlux-based SSE and Streamable HTTP transport implementation for reactive applications.
|
||||
- `io.modelcontextprotocol.sdk:mcp-spring-webmvc` - WebMVC-based SSE and Streamable HTTP transport implementation for servlet-based applications.
|
||||
- **Optional Spring Transport Dependencies** (part of [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+, group `org.springframework.ai`)
|
||||
- `org.springframework.ai:mcp-spring-webflux` - WebFlux-based SSE and Streamable HTTP transport implementation for reactive applications.
|
||||
- `org.springframework.ai:mcp-spring-webmvc` - WebMVC-based SSE and Streamable HTTP transport implementation for servlet-based applications.
|
||||
- **Testing Dependencies**
|
||||
- `io.modelcontextprotocol.sdk:mcp-test` - Testing utilities and support for MCP-based applications.
|
||||
|
|
|
|||
150
docs/server.md
150
docs/server.md
|
|
@ -21,7 +21,8 @@ The MCP Server is a foundational component in the Model Context Protocol (MCP) a
|
|||
!!! tip
|
||||
The core `io.modelcontextprotocol.sdk:mcp` module provides STDIO, SSE, and Streamable HTTP server transport implementations without requiring external web frameworks.
|
||||
|
||||
Spring-specific transport implementations are available as **optional** dependencies `io.modelcontextprotocol.sdk:mcp-spring-webflux`, `io.modelcontextprotocol.sdk:mcp-spring-webmvc` for [Spring Framework](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html) users.
|
||||
Spring-specific transport implementations (`mcp-spring-webflux`, `mcp-spring-webmvc`) are now part of [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`) and are no longer shipped by this SDK.
|
||||
See the [MCP Server Boot Starter](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-server-boot-starter-docs.html) documentation for Spring-based server setup.
|
||||
|
||||
The server supports both synchronous and asynchronous APIs, allowing for flexible integration in different application contexts.
|
||||
|
||||
|
|
@ -104,25 +105,27 @@ The transport layer in the MCP SDK is responsible for handling the communication
|
|||
It provides different implementations to support various communication protocols and patterns.
|
||||
The SDK includes several built-in transport provider implementations:
|
||||
|
||||
=== "STDIO"
|
||||
### STDIO
|
||||
|
||||
Create process-based transport using stdin/stdout:
|
||||
Create process-based transport using stdin/stdout:
|
||||
|
||||
```java
|
||||
StdioServerTransportProvider transportProvider =
|
||||
new StdioServerTransportProvider(new ObjectMapper());
|
||||
```
|
||||
```java
|
||||
StdioServerTransportProvider transportProvider =
|
||||
new StdioServerTransportProvider(new ObjectMapper());
|
||||
```
|
||||
|
||||
Provides bidirectional JSON-RPC message handling over standard input/output streams with non-blocking message processing, serialization/deserialization, and graceful shutdown support.
|
||||
Provides bidirectional JSON-RPC message handling over standard input/output streams with non-blocking message processing, serialization/deserialization, and graceful shutdown support.
|
||||
|
||||
Key features:
|
||||
Key features:
|
||||
|
||||
- Bidirectional communication through stdin/stdout
|
||||
- Process-based integration support
|
||||
- Simple setup and configuration
|
||||
- Lightweight implementation
|
||||
- Bidirectional communication through stdin/stdout
|
||||
- Process-based integration support
|
||||
- Simple setup and configuration
|
||||
- Lightweight implementation
|
||||
|
||||
=== "Streamable HTTP (Servlet)"
|
||||
### Streamable HTTP
|
||||
|
||||
=== "Streamable HTTP Servlet"
|
||||
|
||||
Creates a Servlet-based Streamable HTTP server transport. Included in the core `mcp` module:
|
||||
|
||||
|
|
@ -165,9 +168,9 @@ The SDK includes several built-in transport provider implementations:
|
|||
- Security validation support
|
||||
- Graceful shutdown support
|
||||
|
||||
=== "Streamable HTTP (WebFlux)"
|
||||
=== "Streamable HTTP WebFlux (external)"
|
||||
|
||||
Creates WebFlux-based Streamable HTTP server transport. Requires the `mcp-spring-webflux` dependency:
|
||||
Creates WebFlux-based Streamable HTTP server transport. Requires the `mcp-spring-webflux` dependency from [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`):
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
|
|
@ -195,9 +198,9 @@ The SDK includes several built-in transport provider implementations:
|
|||
- Configurable keep-alive intervals
|
||||
- Security validation support
|
||||
|
||||
=== "Streamable HTTP (WebMvc)"
|
||||
=== "Streamable HTTP WebMvc (external)"
|
||||
|
||||
Creates WebMvc-based Streamable HTTP server transport. Requires the `mcp-spring-webmvc` dependency:
|
||||
Creates WebMvc-based Streamable HTTP server transport. Requires the `mcp-spring-webmvc` dependency from [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`):
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
|
|
@ -219,61 +222,9 @@ The SDK includes several built-in transport provider implementations:
|
|||
}
|
||||
```
|
||||
|
||||
=== "SSE (WebFlux)"
|
||||
### SSE HTTP (Legacy)
|
||||
|
||||
Creates WebFlux-based SSE server transport. Requires the `mcp-spring-webflux` dependency:
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
class McpConfig {
|
||||
@Bean
|
||||
WebFluxSseServerTransportProvider webFluxSseServerTransportProvider(ObjectMapper mapper) {
|
||||
return new WebFluxSseServerTransportProvider(mapper, "/mcp/message");
|
||||
}
|
||||
|
||||
@Bean
|
||||
RouterFunction<?> mcpRouterFunction(WebFluxSseServerTransportProvider transportProvider) {
|
||||
return transportProvider.getRouterFunction();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implements the MCP HTTP with SSE transport specification, providing:
|
||||
|
||||
- Reactive HTTP streaming with WebFlux
|
||||
- Concurrent client connections through SSE endpoints
|
||||
- Message routing and session management
|
||||
- Graceful shutdown capabilities
|
||||
|
||||
=== "SSE (WebMvc)"
|
||||
|
||||
Creates WebMvc-based SSE server transport. Requires the `mcp-spring-webmvc` dependency:
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
class McpConfig {
|
||||
@Bean
|
||||
WebMvcSseServerTransportProvider webMvcSseServerTransportProvider(ObjectMapper mapper) {
|
||||
return new WebMvcSseServerTransportProvider(mapper, "/mcp/message");
|
||||
}
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> mcpRouterFunction(
|
||||
WebMvcSseServerTransportProvider transportProvider) {
|
||||
return transportProvider.getRouterFunction();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implements the MCP HTTP with SSE transport specification, providing:
|
||||
|
||||
- Server-side event streaming
|
||||
- Integration with Spring WebMVC
|
||||
- Support for traditional web applications
|
||||
- Synchronous operation handling
|
||||
|
||||
=== "SSE (Servlet)"
|
||||
=== "SSE Servlet"
|
||||
|
||||
Creates a Servlet-based SSE server transport. Included in the core `mcp` module.
|
||||
The `HttpServletSseServerTransportProvider` can be used with any Servlet container.
|
||||
|
|
@ -307,6 +258,61 @@ The SDK includes several built-in transport provider implementations:
|
|||
- Error handling and response formatting
|
||||
- Graceful shutdown support
|
||||
|
||||
=== "SSE WebFlux (external)"
|
||||
|
||||
Creates WebFlux-based SSE server transport. Requires the `mcp-spring-webflux` dependency from [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`):
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
class McpConfig {
|
||||
@Bean
|
||||
WebFluxSseServerTransportProvider webFluxSseServerTransportProvider(ObjectMapper mapper) {
|
||||
return new WebFluxSseServerTransportProvider(mapper, "/mcp/message");
|
||||
}
|
||||
|
||||
@Bean
|
||||
RouterFunction<?> mcpRouterFunction(WebFluxSseServerTransportProvider transportProvider) {
|
||||
return transportProvider.getRouterFunction();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implements the MCP HTTP with SSE transport specification, providing:
|
||||
|
||||
- Reactive HTTP streaming with WebFlux
|
||||
- Concurrent client connections through SSE endpoints
|
||||
- Message routing and session management
|
||||
- Graceful shutdown capabilities
|
||||
|
||||
=== "SSE WebMvc (external)"
|
||||
|
||||
Creates WebMvc-based SSE server transport. Requires the `mcp-spring-webmvc` dependency from [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`):
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
class McpConfig {
|
||||
@Bean
|
||||
WebMvcSseServerTransportProvider webMvcSseServerTransportProvider(ObjectMapper mapper) {
|
||||
return new WebMvcSseServerTransportProvider(mapper, "/mcp/message");
|
||||
}
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> mcpRouterFunction(
|
||||
WebMvcSseServerTransportProvider transportProvider) {
|
||||
return transportProvider.getRouterFunction();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implements the MCP HTTP with SSE transport specification, providing:
|
||||
|
||||
- Server-side event streaming
|
||||
- Integration with Spring WebMVC
|
||||
- Support for traditional web applications
|
||||
- Synchronous operation handling
|
||||
|
||||
|
||||
## Server Capabilities
|
||||
|
||||
The server can be configured with various capabilities:
|
||||
|
|
|
|||
|
|
@ -54,20 +54,6 @@
|
|||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MCP Transport - WebFlux SSE -->
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-spring-webflux</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MCP Transport - WebMVC SSE -->
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-spring-webmvc</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
# WebFlux SSE Transport
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-spring-webflux</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
```java
|
||||
String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
@Configuration
|
||||
static class MyConfig {
|
||||
|
||||
// SSE transport
|
||||
@Bean
|
||||
public WebFluxSseServerTransport sseServerTransport() {
|
||||
return new WebFluxSseServerTransport(new ObjectMapper(), "/mcp/message");
|
||||
}
|
||||
|
||||
// Router function for SSE transport used by Spring WebFlux to start an HTTP
|
||||
// server.
|
||||
@Bean
|
||||
public RouterFunction<?> mcpRouterFunction(WebFluxSseServerTransport transport) {
|
||||
return transport.getRouterFunction();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public McpAsyncServer mcpServer(ServerMcpTransport transport, OpenLibrary openLibrary) {
|
||||
|
||||
// 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
|
||||
.logging() // Logging support
|
||||
.build();
|
||||
|
||||
// Create the server with both tool and resource capabilities
|
||||
var server = McpServer.using(transport)
|
||||
.serverInfo("MCP Demo Server", "1.0.0")
|
||||
.capabilities(capabilities)
|
||||
.resources(systemInfoResourceRegistration())
|
||||
.prompts(greetingPromptRegistration())
|
||||
.tools(openLibraryToolRegistrations(openLibrary))
|
||||
.async();
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// ...
|
||||
|
||||
}
|
||||
```
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-parent</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>mcp-spring-webflux</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
<name>WebFlux transports</name>
|
||||
<description>WebFlux implementation for the SSE and Streamable Http Client and Server transports</description>
|
||||
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||
|
||||
<scm>
|
||||
<url>https://github.com/modelcontextprotocol/java-sdk</url>
|
||||
<connection>git://github.com/modelcontextprotocol/java-sdk.git</connection>
|
||||
<developerConnection>git@github.com/modelcontextprotocol/java-sdk.git</developerConnection>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-core</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-test</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-webflux</artifactId>
|
||||
<version>${springframework.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-json-jackson2</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.projectreactor.netty</groupId>
|
||||
<artifactId>reactor-netty-http</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- The Spring Context is required due to the reactor-netty connector being dependant on
|
||||
the Spring Lifecycle, as discussed here:
|
||||
https://github.com/spring-projects/spring-framework/issues/31180 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
<version>${springframework.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-test</artifactId>
|
||||
<version>${springframework.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>${assert4j.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.bytebuddy</groupId>
|
||||
<artifactId>byte-buddy</artifactId>
|
||||
<version>${byte-buddy.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>toxiproxy</artifactId>
|
||||
<version>${toxiproxy.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.awaitility</groupId>
|
||||
<artifactId>awaitility</artifactId>
|
||||
<version>${awaitility.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-params</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>net.javacrumbs.json-unit</groupId>
|
||||
<artifactId>json-unit-assertj</artifactId>
|
||||
<version>${json-unit-assertj.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
</project>
|
||||
|
|
@ -1,625 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import io.modelcontextprotocol.client.McpAsyncClient;
|
||||
import io.modelcontextprotocol.json.McpJsonDefaults;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
import io.modelcontextprotocol.spec.ClosedMcpTransportSession;
|
||||
import io.modelcontextprotocol.spec.DefaultMcpTransportSession;
|
||||
import io.modelcontextprotocol.spec.DefaultMcpTransportStream;
|
||||
import io.modelcontextprotocol.spec.HttpHeaders;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import io.modelcontextprotocol.spec.McpTransportSession;
|
||||
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
|
||||
import io.modelcontextprotocol.spec.McpTransportStream;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.function.Tuple2;
|
||||
import reactor.util.function.Tuples;
|
||||
|
||||
/**
|
||||
* An implementation of the Streamable HTTP protocol as defined by the
|
||||
* <code>2025-03-26</code> version of the MCP specification.
|
||||
*
|
||||
* <p>
|
||||
* The transport is capable of resumability and reconnects. It reacts to transport-level
|
||||
* session invalidation and will propagate {@link McpTransportSessionNotFoundException
|
||||
* appropriate exceptions} to the higher level abstraction layer when needed in order to
|
||||
* allow proper state management. The implementation handles servers that are stateful and
|
||||
* provide session meta information, but can also communicate with stateless servers that
|
||||
* do not provide a session identifier and do not support SSE streams.
|
||||
* </p>
|
||||
* <p>
|
||||
* This implementation does not handle backwards compatibility with the <a href=
|
||||
* "https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">"HTTP
|
||||
* with SSE" transport</a>. In order to communicate over the phased-out
|
||||
* <code>2024-11-05</code> protocol, use {@link HttpClientSseClientTransport} or
|
||||
* {@link WebFluxSseClientTransport}.
|
||||
* </p>
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @see <a href=
|
||||
* "https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">Streamable
|
||||
* HTTP transport specification</a>
|
||||
*/
|
||||
public class WebClientStreamableHttpTransport implements McpClientTransport {
|
||||
|
||||
private static final String MISSING_SESSION_ID = "[missing_session_id]";
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebClientStreamableHttpTransport.class);
|
||||
|
||||
private static final String DEFAULT_ENDPOINT = "/mcp";
|
||||
|
||||
/**
|
||||
* Event type for JSON-RPC messages received through the SSE connection. The server
|
||||
* sends messages with this event type to transmit JSON-RPC protocol data.
|
||||
*/
|
||||
private static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
private static final ParameterizedTypeReference<ServerSentEvent<String>> PARAMETERIZED_TYPE_REF = new ParameterizedTypeReference<>() {
|
||||
};
|
||||
|
||||
private final McpJsonMapper jsonMapper;
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
private final String endpoint;
|
||||
|
||||
private final boolean openConnectionOnStartup;
|
||||
|
||||
private final boolean resumableStreams;
|
||||
|
||||
private final AtomicReference<McpTransportSession<Disposable>> activeSession = new AtomicReference<>();
|
||||
|
||||
private final AtomicReference<Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>>> handler = new AtomicReference<>();
|
||||
|
||||
private final AtomicReference<Consumer<Throwable>> exceptionHandler = new AtomicReference<>();
|
||||
|
||||
private final List<String> supportedProtocolVersions;
|
||||
|
||||
private final String latestSupportedProtocolVersion;
|
||||
|
||||
private WebClientStreamableHttpTransport(McpJsonMapper jsonMapper, WebClient.Builder webClientBuilder,
|
||||
String endpoint, boolean resumableStreams, boolean openConnectionOnStartup,
|
||||
List<String> supportedProtocolVersions) {
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.webClient = webClientBuilder.build();
|
||||
this.endpoint = endpoint;
|
||||
this.resumableStreams = resumableStreams;
|
||||
this.openConnectionOnStartup = openConnectionOnStartup;
|
||||
this.activeSession.set(createTransportSession());
|
||||
this.supportedProtocolVersions = List.copyOf(supportedProtocolVersions);
|
||||
this.latestSupportedProtocolVersion = this.supportedProtocolVersions.stream()
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.findFirst()
|
||||
.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> protocolVersions() {
|
||||
return supportedProtocolVersions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stateful builder for creating {@link WebClientStreamableHttpTransport}
|
||||
* instances.
|
||||
* @param webClientBuilder the {@link WebClient.Builder} to use
|
||||
* @return a builder which will create an instance of
|
||||
* {@link WebClientStreamableHttpTransport} once {@link Builder#build()} is called
|
||||
*/
|
||||
public static Builder builder(WebClient.Builder webClientBuilder) {
|
||||
return new Builder(webClientBuilder);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
|
||||
return Mono.deferContextual(ctx -> {
|
||||
this.handler.set(handler);
|
||||
if (openConnectionOnStartup) {
|
||||
logger.debug("Eagerly opening connection on startup");
|
||||
return this.reconnect(null).then();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private McpTransportSession<Disposable> createTransportSession() {
|
||||
Function<String, Publisher<Void>> onClose = sessionId -> sessionId == null ? Mono.empty()
|
||||
: webClient.delete()
|
||||
.uri(this.endpoint)
|
||||
.header(HttpHeaders.MCP_SESSION_ID, sessionId)
|
||||
.header(HttpHeaders.PROTOCOL_VERSION, this.latestSupportedProtocolVersion)
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.onErrorComplete(e -> {
|
||||
logger.warn("Got error when closing transport", e);
|
||||
return true;
|
||||
})
|
||||
.then();
|
||||
return new DefaultMcpTransportSession(onClose);
|
||||
}
|
||||
|
||||
private McpTransportSession<Disposable> createClosedSession(McpTransportSession<Disposable> existingSession) {
|
||||
var existingSessionId = Optional.ofNullable(existingSession)
|
||||
.filter(session -> !(session instanceof ClosedMcpTransportSession<Disposable>))
|
||||
.flatMap(McpTransportSession::sessionId)
|
||||
.orElse(null);
|
||||
return new ClosedMcpTransportSession<>(existingSessionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setExceptionHandler(Consumer<Throwable> handler) {
|
||||
logger.debug("Exception handler registered");
|
||||
this.exceptionHandler.set(handler);
|
||||
}
|
||||
|
||||
private void handleException(Throwable t) {
|
||||
logger.debug("Handling exception for session {}", sessionIdOrPlaceholder(this.activeSession.get()), t);
|
||||
if (t instanceof McpTransportSessionNotFoundException) {
|
||||
McpTransportSession<?> invalidSession = this.activeSession.getAndSet(createTransportSession());
|
||||
logger.warn("Server does not recognize session {}. Invalidating.", invalidSession.sessionId());
|
||||
invalidSession.close();
|
||||
}
|
||||
Consumer<Throwable> handler = this.exceptionHandler.get();
|
||||
if (handler != null) {
|
||||
handler.accept(t);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.defer(() -> {
|
||||
logger.debug("Graceful close triggered");
|
||||
McpTransportSession<Disposable> currentSession = this.activeSession.getAndUpdate(this::createClosedSession);
|
||||
if (currentSession != null) {
|
||||
return Mono.from(currentSession.closeGracefully());
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Disposable> reconnect(McpTransportStream<Disposable> stream) {
|
||||
return Mono.deferContextual(ctx -> {
|
||||
if (stream != null) {
|
||||
logger.debug("Reconnecting stream {} with lastId {}", stream.streamId(), stream.lastId());
|
||||
}
|
||||
else {
|
||||
logger.debug("Reconnecting with no prior stream");
|
||||
}
|
||||
// Here we attempt to initialize the client. In case the server supports SSE,
|
||||
// we will establish a long-running
|
||||
// session here and listen for messages. If it doesn't, that's ok, the server
|
||||
// is a simple, stateless one.
|
||||
final AtomicReference<Disposable> disposableRef = new AtomicReference<>();
|
||||
final McpTransportSession<Disposable> transportSession = this.activeSession.get();
|
||||
|
||||
Disposable connection = webClient.get()
|
||||
.uri(this.endpoint)
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
.header(HttpHeaders.PROTOCOL_VERSION,
|
||||
ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
|
||||
this.latestSupportedProtocolVersion))
|
||||
.headers(httpHeaders -> {
|
||||
transportSession.sessionId().ifPresent(id -> httpHeaders.add(HttpHeaders.MCP_SESSION_ID, id));
|
||||
if (stream != null) {
|
||||
stream.lastId().ifPresent(id -> httpHeaders.add(HttpHeaders.LAST_EVENT_ID, id));
|
||||
}
|
||||
})
|
||||
.exchangeToFlux(response -> {
|
||||
if (isEventStream(response)) {
|
||||
logger.debug("Established SSE stream via GET");
|
||||
return eventStream(stream, response);
|
||||
}
|
||||
else if (isNotAllowed(response)) {
|
||||
logger.debug("The server does not support SSE streams, using request-response mode.");
|
||||
return Flux.empty();
|
||||
}
|
||||
else if (isNotFound(response)) {
|
||||
if (transportSession.sessionId().isPresent()) {
|
||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
return mcpSessionNotFoundError(sessionIdRepresentation);
|
||||
}
|
||||
else {
|
||||
return this.extractError(response, MISSING_SESSION_ID);
|
||||
}
|
||||
}
|
||||
else {
|
||||
return response.<McpSchema.JSONRPCMessage>createError().doOnError(e -> {
|
||||
logger.info("Opening an SSE stream failed. This can be safely ignored.", e);
|
||||
}).flux();
|
||||
}
|
||||
})
|
||||
.flatMap(jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage)))
|
||||
.onErrorComplete(t -> {
|
||||
this.handleException(t);
|
||||
return true;
|
||||
})
|
||||
.doFinally(s -> {
|
||||
Disposable ref = disposableRef.getAndSet(null);
|
||||
if (ref != null) {
|
||||
transportSession.removeConnection(ref);
|
||||
}
|
||||
})
|
||||
.contextWrite(ctx)
|
||||
.subscribe();
|
||||
|
||||
disposableRef.set(connection);
|
||||
transportSession.addConnection(connection);
|
||||
return Mono.just(connection);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
String jsonText;
|
||||
try {
|
||||
jsonText = jsonMapper.writeValueAsString(message);
|
||||
}
|
||||
catch (IOException e) {
|
||||
return Mono.error(new RuntimeException("Failed to serialize message", e));
|
||||
}
|
||||
return Mono.create(sink -> {
|
||||
logger.debug("Sending message {}", message);
|
||||
// Here we attempt to initialize the client.
|
||||
// In case the server supports SSE, we will establish a long-running session
|
||||
// here and
|
||||
// listen for messages.
|
||||
// If it doesn't, nothing actually happens here, that's just the way it is...
|
||||
final AtomicReference<Disposable> disposableRef = new AtomicReference<>();
|
||||
final McpTransportSession<Disposable> transportSession = this.activeSession.get();
|
||||
|
||||
Disposable connection = Flux.deferContextual(ctx -> webClient.post()
|
||||
.uri(this.endpoint)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON, MediaType.TEXT_EVENT_STREAM)
|
||||
.header(HttpHeaders.PROTOCOL_VERSION,
|
||||
ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
|
||||
this.latestSupportedProtocolVersion))
|
||||
.headers(httpHeaders -> {
|
||||
transportSession.sessionId().ifPresent(id -> httpHeaders.add(HttpHeaders.MCP_SESSION_ID, id));
|
||||
})
|
||||
.bodyValue(jsonText)
|
||||
.exchangeToFlux(response -> {
|
||||
if (transportSession
|
||||
.markInitialized(response.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID))) {
|
||||
// Once we have a session, we try to open an async stream for
|
||||
// the server to send notifications and requests out-of-band.
|
||||
reconnect(null).contextWrite(sink.contextView()).subscribe();
|
||||
}
|
||||
|
||||
String sessionRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
|
||||
// The spec mentions only ACCEPTED, but the existing SDKs can return
|
||||
// 200 OK for notifications
|
||||
if (response.statusCode().is2xxSuccessful()) {
|
||||
Optional<MediaType> contentType = response.headers().contentType();
|
||||
long contentLength = response.headers().contentLength().orElse(-1);
|
||||
// Existing SDKs consume notifications with no response body nor
|
||||
// content type
|
||||
if (contentType.isEmpty() || contentLength == 0
|
||||
|| response.statusCode().equals(HttpStatus.ACCEPTED)) {
|
||||
logger.trace("Message was successfully sent via POST for session {}",
|
||||
sessionRepresentation);
|
||||
// signal the caller that the message was successfully
|
||||
// delivered
|
||||
sink.success();
|
||||
// communicate to downstream there is no streamed data coming
|
||||
return Flux.empty();
|
||||
}
|
||||
else {
|
||||
MediaType mediaType = contentType.get();
|
||||
if (mediaType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM)) {
|
||||
logger.debug("Established SSE stream via POST");
|
||||
// communicate to caller that the message was delivered
|
||||
sink.success();
|
||||
// starting a stream
|
||||
return newEventStream(response, sessionRepresentation);
|
||||
}
|
||||
else if (mediaType.isCompatibleWith(MediaType.APPLICATION_JSON)) {
|
||||
logger.trace("Received response to POST for session {}", sessionRepresentation);
|
||||
// communicate to caller the message was delivered
|
||||
sink.success();
|
||||
return directResponseFlux(message, response);
|
||||
}
|
||||
else {
|
||||
logger.warn("Unknown media type {} returned for POST in session {}", contentType,
|
||||
sessionRepresentation);
|
||||
return Flux.error(new RuntimeException("Unknown media type returned: " + contentType));
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (isNotFound(response) && !sessionRepresentation.equals(MISSING_SESSION_ID)) {
|
||||
return mcpSessionNotFoundError(sessionRepresentation);
|
||||
}
|
||||
return this.extractError(response, sessionRepresentation);
|
||||
}
|
||||
}))
|
||||
.flatMap(jsonRpcMessage -> this.handler.get().apply(Mono.just(jsonRpcMessage)))
|
||||
.onErrorComplete(t -> {
|
||||
// handle the error first
|
||||
this.handleException(t);
|
||||
// inform the caller of sendMessage
|
||||
sink.error(t);
|
||||
return true;
|
||||
})
|
||||
.doFinally(s -> {
|
||||
Disposable ref = disposableRef.getAndSet(null);
|
||||
if (ref != null) {
|
||||
transportSession.removeConnection(ref);
|
||||
}
|
||||
})
|
||||
.contextWrite(sink.contextView())
|
||||
.subscribe();
|
||||
disposableRef.set(connection);
|
||||
transportSession.addConnection(connection);
|
||||
});
|
||||
}
|
||||
|
||||
private static Flux<McpSchema.JSONRPCMessage> mcpSessionNotFoundError(String sessionRepresentation) {
|
||||
logger.warn("Session {} was not found on the MCP server", sessionRepresentation);
|
||||
// inform the stream/connection subscriber
|
||||
return Flux.error(new McpTransportSessionNotFoundException(sessionRepresentation));
|
||||
}
|
||||
|
||||
private Flux<McpSchema.JSONRPCMessage> extractError(ClientResponse response, String sessionRepresentation) {
|
||||
return response.<McpSchema.JSONRPCMessage>createError().onErrorResume(e -> {
|
||||
WebClientResponseException responseException = (WebClientResponseException) e;
|
||||
byte[] body = responseException.getResponseBodyAsByteArray();
|
||||
McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = null;
|
||||
Exception toPropagate;
|
||||
try {
|
||||
McpSchema.JSONRPCResponse jsonRpcResponse = jsonMapper.readValue(body, McpSchema.JSONRPCResponse.class);
|
||||
jsonRpcError = jsonRpcResponse.error();
|
||||
toPropagate = jsonRpcError != null ? new McpError(jsonRpcError)
|
||||
: new McpTransportException("Can't parse the jsonResponse " + jsonRpcResponse);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
toPropagate = new McpTransportException("Sending request failed, " + e.getMessage(), e);
|
||||
logger.debug("Received content together with {} HTTP code response: {}", response.statusCode(), body);
|
||||
}
|
||||
|
||||
// Some implementations can return 400 when presented with a
|
||||
// session id that it doesn't know about, so we will
|
||||
// invalidate the session
|
||||
// https://github.com/modelcontextprotocol/typescript-sdk/issues/389
|
||||
if (responseException.getStatusCode().isSameCodeAs(HttpStatus.BAD_REQUEST)) {
|
||||
if (!sessionRepresentation.equals(MISSING_SESSION_ID)) {
|
||||
return Mono.error(new McpTransportSessionNotFoundException(sessionRepresentation, toPropagate));
|
||||
}
|
||||
return Mono.error(new McpTransportException("Received 400 BAD REQUEST for session "
|
||||
+ sessionRepresentation + ". " + toPropagate.getMessage(), toPropagate));
|
||||
}
|
||||
return Mono.error(toPropagate);
|
||||
}).flux();
|
||||
}
|
||||
|
||||
private Flux<McpSchema.JSONRPCMessage> eventStream(McpTransportStream<Disposable> stream, ClientResponse response) {
|
||||
McpTransportStream<Disposable> sessionStream = stream != null ? stream
|
||||
: new DefaultMcpTransportStream<>(this.resumableStreams, this::reconnect);
|
||||
logger.debug("Connected stream {}", sessionStream.streamId());
|
||||
|
||||
var idWithMessages = response.bodyToFlux(PARAMETERIZED_TYPE_REF).map(this::parse);
|
||||
return Flux.from(sessionStream.consumeSseStream(idWithMessages));
|
||||
}
|
||||
|
||||
private static boolean isNotFound(ClientResponse response) {
|
||||
return response.statusCode().isSameCodeAs(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
private static boolean isNotAllowed(ClientResponse response) {
|
||||
return response.statusCode().isSameCodeAs(HttpStatus.METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
private static boolean isEventStream(ClientResponse response) {
|
||||
return response.statusCode().is2xxSuccessful() && response.headers().contentType().isPresent()
|
||||
&& response.headers().contentType().get().isCompatibleWith(MediaType.TEXT_EVENT_STREAM);
|
||||
}
|
||||
|
||||
private static String sessionIdOrPlaceholder(McpTransportSession<?> transportSession) {
|
||||
return transportSession.sessionId().orElse(MISSING_SESSION_ID);
|
||||
}
|
||||
|
||||
private Flux<McpSchema.JSONRPCMessage> directResponseFlux(McpSchema.JSONRPCMessage sentMessage,
|
||||
ClientResponse response) {
|
||||
return response.bodyToMono(String.class).<Iterable<McpSchema.JSONRPCMessage>>handle((responseMessage, s) -> {
|
||||
try {
|
||||
if (sentMessage instanceof McpSchema.JSONRPCNotification) {
|
||||
logger.warn("Notification: {} received non-compliant response: {}", sentMessage,
|
||||
Utils.hasText(responseMessage) ? responseMessage : "[empty]");
|
||||
s.complete();
|
||||
}
|
||||
else {
|
||||
McpSchema.JSONRPCMessage jsonRpcResponse = McpSchema.deserializeJsonRpcMessage(jsonMapper,
|
||||
responseMessage);
|
||||
s.next(List.of(jsonRpcResponse));
|
||||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
s.error(new McpTransportException(e));
|
||||
}
|
||||
}).flatMapIterable(Function.identity());
|
||||
}
|
||||
|
||||
private Flux<McpSchema.JSONRPCMessage> newEventStream(ClientResponse response, String sessionRepresentation) {
|
||||
McpTransportStream<Disposable> sessionStream = new DefaultMcpTransportStream<>(this.resumableStreams,
|
||||
this::reconnect);
|
||||
logger.trace("Sent POST and opened a stream ({}) for session {}", sessionStream.streamId(),
|
||||
sessionRepresentation);
|
||||
return eventStream(sessionStream, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
|
||||
return this.jsonMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
private Tuple2<Optional<String>, Iterable<McpSchema.JSONRPCMessage>> parse(ServerSentEvent<String> event) {
|
||||
if (MESSAGE_EVENT_TYPE.equals(event.event())) {
|
||||
try {
|
||||
// We don't support batching ATM and probably won't since the next version
|
||||
// considers removing it.
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, event.data());
|
||||
return Tuples.of(Optional.ofNullable(event.id()), List.of(message));
|
||||
}
|
||||
catch (IOException ioException) {
|
||||
throw new McpTransportException("Error parsing JSON-RPC message: " + event.data(), ioException);
|
||||
}
|
||||
}
|
||||
else {
|
||||
logger.debug("Received SSE event with type: {}", event);
|
||||
return Tuples.of(Optional.empty(), List.of());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for {@link WebClientStreamableHttpTransport}.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private McpJsonMapper jsonMapper;
|
||||
|
||||
private WebClient.Builder webClientBuilder;
|
||||
|
||||
private String endpoint = DEFAULT_ENDPOINT;
|
||||
|
||||
private boolean resumableStreams = true;
|
||||
|
||||
private boolean openConnectionOnStartup = false;
|
||||
|
||||
private List<String> supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
|
||||
ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
|
||||
|
||||
private Builder(WebClient.Builder webClientBuilder) {
|
||||
Assert.notNull(webClientBuilder, "WebClient.Builder must not be null");
|
||||
this.webClientBuilder = webClientBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the {@link McpJsonMapper} to use.
|
||||
* @param jsonMapper instance to use
|
||||
* @return the builder instance
|
||||
*/
|
||||
public Builder jsonMapper(McpJsonMapper jsonMapper) {
|
||||
Assert.notNull(jsonMapper, "JsonMapper must not be null");
|
||||
this.jsonMapper = jsonMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the {@link WebClient.Builder} to construct the {@link WebClient}.
|
||||
* @param webClientBuilder instance to use
|
||||
* @return the builder instance
|
||||
*/
|
||||
public Builder webClientBuilder(WebClient.Builder webClientBuilder) {
|
||||
Assert.notNull(webClientBuilder, "WebClient.Builder must not be null");
|
||||
this.webClientBuilder = webClientBuilder;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the endpoint to make HTTP requests against.
|
||||
* @param endpoint endpoint to use
|
||||
* @return the builder instance
|
||||
*/
|
||||
public Builder endpoint(String endpoint) {
|
||||
Assert.hasText(endpoint, "endpoint must be a non-empty String");
|
||||
this.endpoint = endpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure whether to use the stream resumability feature by keeping track of
|
||||
* SSE event ids.
|
||||
* @param resumableStreams if {@code true} event ids will be tracked and upon
|
||||
* disconnection, the last seen id will be used upon reconnection as a header to
|
||||
* resume consuming messages.
|
||||
* @return the builder instance
|
||||
*/
|
||||
public Builder resumableStreams(boolean resumableStreams) {
|
||||
this.resumableStreams = resumableStreams;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure whether the client should open an SSE connection upon startup. Not
|
||||
* all servers support this (although it is in theory possible with the current
|
||||
* specification), so use with caution. By default, this value is {@code false}.
|
||||
* @param openConnectionOnStartup if {@code true} the {@link #connect(Function)}
|
||||
* method call will try to open an SSE connection before sending any JSON-RPC
|
||||
* request
|
||||
* @return the builder instance
|
||||
*/
|
||||
public Builder openConnectionOnStartup(boolean openConnectionOnStartup) {
|
||||
this.openConnectionOnStartup = openConnectionOnStartup;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the list of supported protocol versions used in version negotiation. By
|
||||
* default, the client will send the latest of those versions in the
|
||||
* {@code MCP-Protocol-Version} header.
|
||||
* <p>
|
||||
* Setting this value only updates the values used in version negotiation, and
|
||||
* does NOT impact the actual capabilities of the transport. It should only be
|
||||
* used for compatibility with servers having strict requirements around the
|
||||
* {@code MCP-Protocol-Version} header.
|
||||
* @param supportedProtocolVersions protocol versions supported by this transport
|
||||
* @return this builder
|
||||
* @see <a href=
|
||||
* "https://modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle#version-negotiation">version
|
||||
* negotiation specification</a>
|
||||
* @see <a href=
|
||||
* "https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header">Protocol
|
||||
* Version Header</a>
|
||||
*/
|
||||
public Builder supportedProtocolVersions(List<String> supportedProtocolVersions) {
|
||||
Assert.notEmpty(supportedProtocolVersions, "supportedProtocolVersions must not be empty");
|
||||
this.supportedProtocolVersions = Collections.unmodifiableList(supportedProtocolVersions);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a fresh instance of {@link WebClientStreamableHttpTransport} using
|
||||
* the current builder configuration.
|
||||
* @return a new instance of {@link WebClientStreamableHttpTransport}
|
||||
*/
|
||||
public WebClientStreamableHttpTransport build() {
|
||||
return new WebClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
|
||||
webClientBuilder, endpoint, resumableStreams, openConnectionOnStartup, supportedProtocolVersions);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,413 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import io.modelcontextprotocol.json.McpJsonDefaults;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
|
||||
import io.modelcontextprotocol.spec.HttpHeaders;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.core.publisher.SynchronousSink;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import reactor.util.retry.Retry;
|
||||
import reactor.util.retry.Retry.RetrySignal;
|
||||
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* Server-Sent Events (SSE) implementation of the
|
||||
* {@link io.modelcontextprotocol.spec.McpTransport} that follows the MCP HTTP with SSE
|
||||
* transport specification.
|
||||
*
|
||||
* <p>
|
||||
* This transport establishes a bidirectional communication channel where:
|
||||
* <ul>
|
||||
* <li>Inbound messages are received through an SSE connection from the server</li>
|
||||
* <li>Outbound messages are sent via HTTP POST requests to a server-provided
|
||||
* endpoint</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The message flow follows these steps:
|
||||
* <ol>
|
||||
* <li>The client establishes an SSE connection to the server's /sse endpoint</li>
|
||||
* <li>The server sends an 'endpoint' event containing the URI for sending messages</li>
|
||||
* </ol>
|
||||
*
|
||||
* This implementation uses {@link WebClient} for HTTP communications and supports JSON
|
||||
* serialization/deserialization of messages.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @see <a href=
|
||||
* "https://spec.modelcontextprotocol.io/specification/basic/transports/#http-with-sse">MCP
|
||||
* HTTP with SSE Transport Specification</a>
|
||||
*/
|
||||
public class WebFluxSseClientTransport implements McpClientTransport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebFluxSseClientTransport.class);
|
||||
|
||||
private static final String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2024_11_05;
|
||||
|
||||
/**
|
||||
* Event type for JSON-RPC messages received through the SSE connection. The server
|
||||
* sends messages with this event type to transmit JSON-RPC protocol data.
|
||||
*/
|
||||
private static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
/**
|
||||
* Event type for receiving the message endpoint URI from the server. The server MUST
|
||||
* send this event when a client connects, providing the URI where the client should
|
||||
* send its messages via HTTP POST.
|
||||
*/
|
||||
private static final String ENDPOINT_EVENT_TYPE = "endpoint";
|
||||
|
||||
/**
|
||||
* Default SSE endpoint path as specified by the MCP transport specification. This
|
||||
* endpoint is used to establish the SSE connection with the server.
|
||||
*/
|
||||
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
|
||||
|
||||
/**
|
||||
* Type reference for parsing SSE events containing string data.
|
||||
*/
|
||||
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_TYPE = new ParameterizedTypeReference<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* WebClient instance for handling both SSE connections and HTTP POST requests. Used
|
||||
* for establishing the SSE connection and sending outbound messages.
|
||||
*/
|
||||
private final WebClient webClient;
|
||||
|
||||
/**
|
||||
* JSON mapper for serializing outbound messages and deserializing inbound messages.
|
||||
* Handles conversion between JSON-RPC messages and their string representation.
|
||||
*/
|
||||
protected McpJsonMapper jsonMapper;
|
||||
|
||||
/**
|
||||
* Subscription for the SSE connection handling inbound messages. Used for cleanup
|
||||
* during transport shutdown.
|
||||
*/
|
||||
private Disposable inboundSubscription;
|
||||
|
||||
/**
|
||||
* Flag indicating if the transport is in the process of shutting down. Used to
|
||||
* prevent new operations during shutdown and handle cleanup gracefully.
|
||||
*/
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
/**
|
||||
* Sink for managing the message endpoint URI provided by the server. Stores the most
|
||||
* recent endpoint URI and makes it available for outbound message processing.
|
||||
*/
|
||||
protected final Sinks.One<String> messageEndpointSink = Sinks.one();
|
||||
|
||||
/**
|
||||
* The SSE endpoint URI provided by the server. Used for sending outbound messages via
|
||||
* HTTP POST requests.
|
||||
*/
|
||||
private String sseEndpoint;
|
||||
|
||||
/**
|
||||
* Constructs a new SseClientTransport with the specified WebClient builder and
|
||||
* ObjectMapper. Initializes both inbound and outbound message processing pipelines.
|
||||
* @param webClientBuilder the WebClient.Builder to use for creating the WebClient
|
||||
* instance
|
||||
* @param jsonMapper the ObjectMapper to use for JSON processing
|
||||
* @throws IllegalArgumentException if either parameter is null
|
||||
*/
|
||||
public WebFluxSseClientTransport(WebClient.Builder webClientBuilder, McpJsonMapper jsonMapper) {
|
||||
this(webClientBuilder, jsonMapper, DEFAULT_SSE_ENDPOINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new SseClientTransport with the specified WebClient builder and
|
||||
* ObjectMapper. Initializes both inbound and outbound message processing pipelines.
|
||||
* @param webClientBuilder the WebClient.Builder to use for creating the WebClient
|
||||
* instance
|
||||
* @param jsonMapper the ObjectMapper to use for JSON processing
|
||||
* @param sseEndpoint the SSE endpoint URI to use for establishing the connection
|
||||
* @throws IllegalArgumentException if either parameter is null
|
||||
*/
|
||||
public WebFluxSseClientTransport(WebClient.Builder webClientBuilder, McpJsonMapper jsonMapper, String sseEndpoint) {
|
||||
Assert.notNull(jsonMapper, "jsonMapper must not be null");
|
||||
Assert.notNull(webClientBuilder, "WebClient.Builder must not be null");
|
||||
Assert.hasText(sseEndpoint, "SSE endpoint must not be null or empty");
|
||||
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.webClient = webClientBuilder.build();
|
||||
this.sseEndpoint = sseEndpoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(MCP_PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a connection to the MCP server using Server-Sent Events (SSE). This
|
||||
* method initiates the SSE connection and sets up the message processing pipeline.
|
||||
*
|
||||
* <p>
|
||||
* The connection process follows these steps:
|
||||
* <ol>
|
||||
* <li>Establishes an SSE connection to the server's /sse endpoint</li>
|
||||
* <li>Waits for the server to send an 'endpoint' event with the message posting
|
||||
* URI</li>
|
||||
* <li>Sets up message handling for incoming JSON-RPC messages</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* The connection is considered established only after receiving the endpoint event
|
||||
* from the server.
|
||||
* @param handler a function that processes incoming JSON-RPC messages and returns
|
||||
* responses
|
||||
* @return a Mono that completes when the connection is fully established
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
|
||||
// TODO: Avoid eager connection opening and enable resilience
|
||||
// -> upon disconnects, re-establish connection
|
||||
// -> allow optimizing for eager connection start using a constructor flag
|
||||
Flux<ServerSentEvent<String>> events = eventStream();
|
||||
this.inboundSubscription = events.concatMap(event -> Mono.just(event).<JSONRPCMessage>handle((e, s) -> {
|
||||
if (ENDPOINT_EVENT_TYPE.equals(event.event())) {
|
||||
String messageEndpointUri = event.data();
|
||||
if (messageEndpointSink.tryEmitValue(messageEndpointUri).isSuccess()) {
|
||||
s.complete();
|
||||
}
|
||||
else {
|
||||
// TODO: clarify with the spec if multiple events can be
|
||||
// received
|
||||
s.error(new RuntimeException("Failed to handle SSE endpoint event"));
|
||||
}
|
||||
}
|
||||
else if (MESSAGE_EVENT_TYPE.equals(event.event())) {
|
||||
try {
|
||||
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, event.data());
|
||||
s.next(message);
|
||||
}
|
||||
catch (IOException ioException) {
|
||||
s.error(ioException);
|
||||
}
|
||||
}
|
||||
else {
|
||||
logger.debug("Received unrecognized SSE event type: {}", event);
|
||||
s.complete();
|
||||
}
|
||||
}).transform(handler)).subscribe();
|
||||
|
||||
// The connection is established once the server sends the endpoint event
|
||||
return messageEndpointSink.asMono().then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC message to the server using the endpoint provided during
|
||||
* connection.
|
||||
*
|
||||
* <p>
|
||||
* Messages are sent via HTTP POST requests to the server-provided endpoint URI. The
|
||||
* message is serialized to JSON before transmission. If the transport is in the
|
||||
* process of closing, the message send operation is skipped gracefully.
|
||||
* @param message the JSON-RPC message to send
|
||||
* @return a Mono that completes when the message has been sent successfully
|
||||
* @throws RuntimeException if message serialization fails
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> sendMessage(JSONRPCMessage message) {
|
||||
// The messageEndpoint is the endpoint URI to send the messages
|
||||
// It is provided by the server as part of the endpoint event
|
||||
return messageEndpointSink.asMono().flatMap(messageEndpointUri -> {
|
||||
if (isClosing) {
|
||||
return Mono.empty();
|
||||
}
|
||||
try {
|
||||
String jsonText = this.jsonMapper.writeValueAsString(message);
|
||||
return webClient.post()
|
||||
.uri(messageEndpointUri)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
|
||||
.bodyValue(jsonText)
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.doOnSuccess(response -> {
|
||||
logger.debug("Message sent successfully");
|
||||
})
|
||||
.doOnError(error -> {
|
||||
if (!isClosing) {
|
||||
logger.error("Error sending message: {}", error.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (IOException e) {
|
||||
if (!isClosing) {
|
||||
return Mono.error(new RuntimeException("Failed to serialize message", e));
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
}).then(); // TODO: Consider non-200-ok response
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes and starts the inbound SSE event processing. Establishes the SSE
|
||||
* connection and sets up event handling for both message and endpoint events.
|
||||
* Includes automatic retry logic for handling transient connection failures.
|
||||
*/
|
||||
// visible for tests
|
||||
protected Flux<ServerSentEvent<String>> eventStream() {// @formatter:off
|
||||
return this.webClient
|
||||
.get()
|
||||
.uri(this.sseEndpoint)
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
|
||||
.retrieve()
|
||||
.bodyToFlux(SSE_TYPE)
|
||||
.retryWhen(Retry.from(retrySignal -> retrySignal.handle(inboundRetryHandler)));
|
||||
} // @formatter:on
|
||||
|
||||
/**
|
||||
* Retry handler for the inbound SSE stream. Implements the retry logic for handling
|
||||
* connection failures and other errors.
|
||||
*/
|
||||
private BiConsumer<RetrySignal, SynchronousSink<Object>> inboundRetryHandler = (retrySpec, sink) -> {
|
||||
if (isClosing) {
|
||||
logger.debug("SSE connection closed during shutdown");
|
||||
sink.error(retrySpec.failure());
|
||||
return;
|
||||
}
|
||||
if (retrySpec.failure() instanceof IOException) {
|
||||
logger.debug("Retrying SSE connection after IO error");
|
||||
sink.next(retrySpec);
|
||||
return;
|
||||
}
|
||||
logger.error("Fatal SSE error, not retrying: {}", retrySpec.failure().getMessage());
|
||||
sink.error(retrySpec.failure());
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements graceful shutdown of the transport. Cleans up all resources including
|
||||
* subscriptions and schedulers. Ensures orderly shutdown of both inbound and outbound
|
||||
* message processing.
|
||||
* @return a Mono that completes when shutdown is finished
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() { // @formatter:off
|
||||
return Mono.fromRunnable(() -> {
|
||||
isClosing = true;
|
||||
|
||||
// Dispose of subscriptions
|
||||
|
||||
if (inboundSubscription != null) {
|
||||
inboundSubscription.dispose();
|
||||
}
|
||||
|
||||
})
|
||||
.then()
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
} // @formatter:on
|
||||
|
||||
/**
|
||||
* Unmarshalls data from a generic Object into the specified type using the configured
|
||||
* ObjectMapper.
|
||||
*
|
||||
* <p>
|
||||
* This method is particularly useful when working with JSON-RPC parameters or result
|
||||
* objects that need to be converted to specific Java types. It leverages Jackson's
|
||||
* type conversion capabilities to handle complex object structures.
|
||||
* @param <T> the target type to convert the data into
|
||||
* @param data the source object to convert
|
||||
* @param typeRef the TypeRef describing the target type
|
||||
* @return the unmarshalled object of type T
|
||||
* @throws IllegalArgumentException if the conversion cannot be performed
|
||||
*/
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
|
||||
return this.jsonMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new builder for {@link WebFluxSseClientTransport}.
|
||||
* @param webClientBuilder the WebClient.Builder to use for creating the WebClient
|
||||
* instance
|
||||
* @return a new builder instance
|
||||
*/
|
||||
public static Builder builder(WebClient.Builder webClientBuilder) {
|
||||
return new Builder(webClientBuilder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for {@link WebFluxSseClientTransport}.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
|
||||
private String sseEndpoint = DEFAULT_SSE_ENDPOINT;
|
||||
|
||||
private McpJsonMapper jsonMapper;
|
||||
|
||||
/**
|
||||
* Creates a new builder with the specified WebClient.Builder.
|
||||
* @param webClientBuilder the WebClient.Builder to use
|
||||
*/
|
||||
public Builder(WebClient.Builder webClientBuilder) {
|
||||
Assert.notNull(webClientBuilder, "WebClient.Builder must not be null");
|
||||
this.webClientBuilder = webClientBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the SSE endpoint path.
|
||||
* @param sseEndpoint the SSE endpoint path
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder sseEndpoint(String sseEndpoint) {
|
||||
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
|
||||
this.sseEndpoint = sseEndpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the JSON mapper for serialization/deserialization.
|
||||
* @param jsonMapper the JsonMapper to use
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder jsonMapper(McpJsonMapper jsonMapper) {
|
||||
Assert.notNull(jsonMapper, "jsonMapper must not be null");
|
||||
this.jsonMapper = jsonMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new {@link WebFluxSseClientTransport} instance.
|
||||
* @return a new transport instance
|
||||
*/
|
||||
public WebFluxSseClientTransport build() {
|
||||
return new WebFluxSseClientTransport(webClientBuilder,
|
||||
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, sseEndpoint);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,571 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025-2026 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.json.McpJsonDefaults;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpServerSession;
|
||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.KeepAliveScheduler;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.Exceptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.FluxSink;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
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;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Server-side implementation of the MCP (Model Context Protocol) HTTP transport using
|
||||
* Server-Sent Events (SSE). This implementation provides a bidirectional communication
|
||||
* channel between MCP clients and servers using HTTP POST for client-to-server messages
|
||||
* and SSE for server-to-client messages.
|
||||
*
|
||||
* <p>
|
||||
* Key features:
|
||||
* <ul>
|
||||
* <li>Implements the {@link McpServerTransportProvider} interface that allows managing
|
||||
* {@link McpServerSession} instances and enabling their communication with the
|
||||
* {@link McpServerTransport} abstraction.</li>
|
||||
* <li>Uses WebFlux for non-blocking request handling and SSE support</li>
|
||||
* <li>Maintains client sessions for reliable message delivery</li>
|
||||
* <li>Supports graceful shutdown with session cleanup</li>
|
||||
* <li>Thread-safe message broadcasting to multiple clients</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The transport sets up two main endpoints:
|
||||
* <ul>
|
||||
* <li>SSE endpoint (/sse) - For establishing SSE connections with clients</li>
|
||||
* <li>Message endpoint (configurable) - For receiving JSON-RPC messages from clients</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This implementation is thread-safe and can handle multiple concurrent client
|
||||
* connections. It uses {@link ConcurrentHashMap} for session management and Project
|
||||
* Reactor's non-blocking APIs for message processing and delivery.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Alexandros Pappas
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @see McpServerTransport
|
||||
* @see ServerSentEvent
|
||||
*/
|
||||
public class WebFluxSseServerTransportProvider implements McpServerTransportProvider {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebFluxSseServerTransportProvider.class);
|
||||
|
||||
/**
|
||||
* Event type for JSON-RPC messages sent through the SSE connection.
|
||||
*/
|
||||
public static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
/**
|
||||
* Event type for sending the message endpoint URI to clients.
|
||||
*/
|
||||
public static final String ENDPOINT_EVENT_TYPE = "endpoint";
|
||||
|
||||
private static final String MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||
|
||||
/**
|
||||
* Default SSE endpoint path as specified by the MCP transport specification.
|
||||
*/
|
||||
public static final String DEFAULT_SSE_ENDPOINT = "/sse";
|
||||
|
||||
public static final String SESSION_ID = "sessionId";
|
||||
|
||||
public static final String DEFAULT_BASE_URL = "";
|
||||
|
||||
private final McpJsonMapper jsonMapper;
|
||||
|
||||
/**
|
||||
* Base URL for the message endpoint. This is used to construct the full URL for
|
||||
* clients to send their JSON-RPC messages.
|
||||
*/
|
||||
private final String baseUrl;
|
||||
|
||||
private final String messageEndpoint;
|
||||
|
||||
private final String sseEndpoint;
|
||||
|
||||
private final RouterFunction<?> routerFunction;
|
||||
|
||||
private McpServerSession.Factory sessionFactory;
|
||||
|
||||
/**
|
||||
* Map of active client sessions, keyed by session ID.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, McpServerSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor;
|
||||
|
||||
/**
|
||||
* Flag indicating if the transport is shutting down.
|
||||
*/
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
/**
|
||||
* Keep-alive scheduler for managing session pings. Activated if keepAliveInterval is
|
||||
* set. Disabled by default.
|
||||
*/
|
||||
private KeepAliveScheduler keepAliveScheduler;
|
||||
|
||||
/**
|
||||
* Security validator for validating HTTP requests.
|
||||
*/
|
||||
private final ServerTransportSecurityValidator securityValidator;
|
||||
|
||||
/**
|
||||
* Constructs a new WebFlux SSE server transport provider instance.
|
||||
* @param jsonMapper The ObjectMapper to use for JSON serialization/deserialization of
|
||||
* MCP messages. Must not be null.
|
||||
* @param baseUrl webflux message base path
|
||||
* @param messageEndpoint The endpoint URI where clients should send their JSON-RPC
|
||||
* messages. This endpoint will be communicated to clients during SSE connection
|
||||
* setup. Must not be null.
|
||||
* @param sseEndpoint The SSE endpoint path. Must not be null.
|
||||
* @param keepAliveInterval The interval for sending keep-alive pings to clients.
|
||||
* @param contextExtractor The context extractor to use for extracting MCP transport
|
||||
* context from HTTP requests. Must not be null.
|
||||
* @param securityValidator The security validator for validating HTTP requests.
|
||||
* @throws IllegalArgumentException if either parameter is null
|
||||
*/
|
||||
private WebFluxSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint,
|
||||
String sseEndpoint, Duration keepAliveInterval,
|
||||
McpTransportContextExtractor<ServerRequest> contextExtractor,
|
||||
ServerTransportSecurityValidator securityValidator) {
|
||||
Assert.notNull(jsonMapper, "ObjectMapper must not be null");
|
||||
Assert.notNull(baseUrl, "Message base path must not be null");
|
||||
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
|
||||
Assert.notNull(sseEndpoint, "SSE endpoint must not be null");
|
||||
Assert.notNull(contextExtractor, "Context extractor must not be null");
|
||||
Assert.notNull(securityValidator, "Security validator must not be null");
|
||||
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.baseUrl = baseUrl;
|
||||
this.messageEndpoint = messageEndpoint;
|
||||
this.sseEndpoint = sseEndpoint;
|
||||
this.contextExtractor = contextExtractor;
|
||||
this.securityValidator = securityValidator;
|
||||
this.routerFunction = RouterFunctions.route()
|
||||
.GET(this.sseEndpoint, this::handleSseConnection)
|
||||
.POST(this.messageEndpoint, this::handleMessage)
|
||||
.build();
|
||||
|
||||
if (keepAliveInterval != null) {
|
||||
|
||||
this.keepAliveScheduler = KeepAliveScheduler
|
||||
.builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(sessions.values()))
|
||||
.initialDelay(keepAliveInterval)
|
||||
.interval(keepAliveInterval)
|
||||
.build();
|
||||
|
||||
this.keepAliveScheduler.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(ProtocolVersions.MCP_2024_11_05);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSessionFactory(McpServerSession.Factory sessionFactory) {
|
||||
this.sessionFactory = sessionFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a JSON-RPC message to all connected clients through their SSE
|
||||
* connections. The message is serialized to JSON and sent as a server-sent event to
|
||||
* each active session.
|
||||
*
|
||||
* <p>
|
||||
* The method:
|
||||
* <ul>
|
||||
* <li>Serializes the message to JSON</li>
|
||||
* <li>Creates a server-sent event with the message data</li>
|
||||
* <li>Attempts to send the event to all active sessions</li>
|
||||
* <li>Tracks and reports any delivery failures</li>
|
||||
* </ul>
|
||||
* @param method The JSON-RPC method to send to clients
|
||||
* @param params The method parameters to send to clients
|
||||
* @return A Mono that completes when the message has been sent to all sessions, or
|
||||
* errors if any session fails to receive the message
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> notifyClients(String method, Object params) {
|
||||
if (sessions.isEmpty()) {
|
||||
logger.debug("No active sessions to broadcast message to");
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
logger.debug("Attempting to broadcast message to {} active sessions", sessions.size());
|
||||
|
||||
return Flux.fromIterable(sessions.values())
|
||||
.flatMap(session -> session.sendNotification(method, params)
|
||||
.doOnError(
|
||||
e -> logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()))
|
||||
.onErrorComplete())
|
||||
.then();
|
||||
}
|
||||
|
||||
// FIXME: This javadoc makes claims about using isClosing flag but it's not
|
||||
// actually
|
||||
// doing that.
|
||||
|
||||
/**
|
||||
* Initiates a graceful shutdown of all the sessions. This method ensures all active
|
||||
* sessions are properly closed and cleaned up.
|
||||
* @return A Mono that completes when all sessions have been closed
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Flux.fromIterable(sessions.values())
|
||||
.doFirst(() -> logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size()))
|
||||
.flatMap(McpServerSession::closeGracefully)
|
||||
.then()
|
||||
.doOnSuccess(v -> {
|
||||
logger.debug("Graceful shutdown completed");
|
||||
sessions.clear();
|
||||
if (this.keepAliveScheduler != null) {
|
||||
this.keepAliveScheduler.shutdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebFlux router function that defines the transport's HTTP endpoints.
|
||||
* This router function should be integrated into the application's web configuration.
|
||||
*
|
||||
* <p>
|
||||
* The router function defines two endpoints:
|
||||
* <ul>
|
||||
* <li>GET {sseEndpoint} - For establishing SSE connections</li>
|
||||
* <li>POST {messageEndpoint} - For receiving client messages</li>
|
||||
* </ul>
|
||||
* @return The configured {@link RouterFunction} for handling HTTP requests
|
||||
*/
|
||||
public RouterFunction<?> getRouterFunction() {
|
||||
return this.routerFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles new SSE connection requests from clients. Creates a new session for each
|
||||
* connection and sets up the SSE event stream.
|
||||
* @param request The incoming server request
|
||||
* @return A Mono which emits a response with the SSE event stream
|
||||
*/
|
||||
private Mono<ServerResponse> handleSseConnection(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, List<String>> headers = request.headers().asHttpHeaders();
|
||||
this.securityValidator.validateHeaders(headers);
|
||||
}
|
||||
catch (ServerTransportSecurityException e) {
|
||||
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request);
|
||||
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.body(Flux.<ServerSentEvent<?>>create(sink -> {
|
||||
WebFluxMcpSessionTransport sessionTransport = new WebFluxMcpSessionTransport(sink);
|
||||
|
||||
McpServerSession session = sessionFactory.create(sessionTransport);
|
||||
String sessionId = session.getId();
|
||||
|
||||
logger.debug("Created new SSE connection for session: {}", sessionId);
|
||||
sessions.put(sessionId, session);
|
||||
|
||||
// Send initial endpoint event
|
||||
logger.debug("Sending initial endpoint event to session: {}", sessionId);
|
||||
sink.next(
|
||||
ServerSentEvent.builder().event(ENDPOINT_EVENT_TYPE).data(buildEndpointUrl(sessionId)).build());
|
||||
sink.onCancel(() -> {
|
||||
logger.debug("Session {} cancelled", sessionId);
|
||||
sessions.remove(sessionId);
|
||||
});
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), ServerSentEvent.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the full message endpoint URL by combining the base URL, message path,
|
||||
* and the required session_id query parameter.
|
||||
* @param sessionId the unique session identifier
|
||||
* @return the fully qualified endpoint URL as a string
|
||||
*/
|
||||
private String buildEndpointUrl(String sessionId) {
|
||||
// for WebMVC compatibility
|
||||
return UriComponentsBuilder.fromUriString(this.baseUrl)
|
||||
.path(this.messageEndpoint)
|
||||
.queryParam(SESSION_ID, sessionId)
|
||||
.build()
|
||||
.toUriString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming JSON-RPC messages from clients. Deserializes the message and
|
||||
* processes it through the configured message handler.
|
||||
*
|
||||
* <p>
|
||||
* The handler:
|
||||
* <ul>
|
||||
* <li>Deserializes the incoming JSON-RPC message</li>
|
||||
* <li>Passes it through the message handler chain</li>
|
||||
* <li>Returns appropriate HTTP responses based on processing results</li>
|
||||
* <li>Handles various error conditions with appropriate error responses</li>
|
||||
* </ul>
|
||||
* @param request The incoming server request containing the JSON-RPC message
|
||||
* @return A Mono emitting the response indicating the message processing result
|
||||
*/
|
||||
private Mono<ServerResponse> handleMessage(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, List<String>> headers = request.headers().asHttpHeaders();
|
||||
this.securityValidator.validateHeaders(headers);
|
||||
}
|
||||
catch (ServerTransportSecurityException e) {
|
||||
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
|
||||
}
|
||||
|
||||
if (request.queryParam("sessionId").isEmpty()) {
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Session ID missing in message endpoint"));
|
||||
}
|
||||
|
||||
McpServerSession session = sessions.get(request.queryParam("sessionId").get());
|
||||
|
||||
if (session == null) {
|
||||
return ServerResponse.status(HttpStatus.NOT_FOUND)
|
||||
.bodyValue(new McpError("Session not found: " + request.queryParam("sessionId").get()));
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request);
|
||||
|
||||
return request.bodyToMono(String.class).flatMap(body -> {
|
||||
try {
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
|
||||
return session.handle(message).flatMap(response -> ServerResponse.ok().build()).onErrorResume(error -> {
|
||||
logger.error("Error processing message: {}", error.getMessage());
|
||||
// TODO: instead of signalling the error, just respond with 200 OK
|
||||
// - the error is signalled on the SSE connection
|
||||
// return ServerResponse.ok().build();
|
||||
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.bodyValue(new McpError(error.getMessage()));
|
||||
});
|
||||
}
|
||||
catch (IllegalArgumentException | IOException e) {
|
||||
logger.error("Failed to deserialize message: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Invalid message format"));
|
||||
}
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext));
|
||||
}
|
||||
|
||||
private class WebFluxMcpSessionTransport implements McpServerTransport {
|
||||
|
||||
private final FluxSink<ServerSentEvent<?>> sink;
|
||||
|
||||
public WebFluxMcpSessionTransport(FluxSink<ServerSentEvent<?>> sink) {
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
return Mono.fromSupplier(() -> {
|
||||
try {
|
||||
return jsonMapper.writeValueAsString(message);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw Exceptions.propagate(e);
|
||||
}
|
||||
}).doOnNext(jsonText -> {
|
||||
ServerSentEvent<Object> event = ServerSentEvent.builder()
|
||||
.event(MESSAGE_EVENT_TYPE)
|
||||
.data(jsonText)
|
||||
.build();
|
||||
sink.next(event);
|
||||
}).doOnError(e -> {
|
||||
// TODO log with sessionid
|
||||
Throwable exception = Exceptions.unwrap(e);
|
||||
sink.error(exception);
|
||||
}).then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
|
||||
return jsonMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(sink::complete);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
sink.complete();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for creating instances of {@link WebFluxSseServerTransportProvider}.
|
||||
* <p>
|
||||
* This builder provides a fluent API for configuring and creating instances of
|
||||
* WebFluxSseServerTransportProvider with custom settings.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private McpJsonMapper jsonMapper;
|
||||
|
||||
private String baseUrl = DEFAULT_BASE_URL;
|
||||
|
||||
private String messageEndpoint;
|
||||
|
||||
private String sseEndpoint = DEFAULT_SSE_ENDPOINT;
|
||||
|
||||
private Duration keepAliveInterval;
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor = (
|
||||
serverRequest) -> McpTransportContext.EMPTY;
|
||||
|
||||
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
|
||||
|
||||
/**
|
||||
* Sets the McpJsonMapper to use for JSON serialization/deserialization of MCP
|
||||
* messages.
|
||||
* @param jsonMapper The McpJsonMapper instance. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if jsonMapper is null
|
||||
*/
|
||||
public Builder jsonMapper(McpJsonMapper jsonMapper) {
|
||||
Assert.notNull(jsonMapper, "JsonMapper must not be null");
|
||||
this.jsonMapper = jsonMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the project basePath as endpoint prefix where clients should send their
|
||||
* JSON-RPC messages
|
||||
* @param baseUrl the message basePath . Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if basePath is null
|
||||
*/
|
||||
public Builder basePath(String baseUrl) {
|
||||
Assert.notNull(baseUrl, "basePath must not be null");
|
||||
this.baseUrl = baseUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the endpoint URI where clients should send their JSON-RPC messages.
|
||||
* @param messageEndpoint The message endpoint URI. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if messageEndpoint is null
|
||||
*/
|
||||
public Builder messageEndpoint(String messageEndpoint) {
|
||||
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
|
||||
this.messageEndpoint = messageEndpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the SSE endpoint path.
|
||||
* @param sseEndpoint The SSE endpoint path. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if sseEndpoint is null
|
||||
*/
|
||||
public Builder sseEndpoint(String sseEndpoint) {
|
||||
Assert.notNull(sseEndpoint, "SSE endpoint must not be null");
|
||||
this.sseEndpoint = sseEndpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the interval for sending keep-alive pings to clients.
|
||||
* @param keepAliveInterval The keep-alive interval duration. If null, keep-alive
|
||||
* is disabled.
|
||||
* @return this builder instance
|
||||
*/
|
||||
public Builder keepAliveInterval(Duration keepAliveInterval) {
|
||||
this.keepAliveInterval = keepAliveInterval;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the context extractor that allows providing the MCP feature
|
||||
* implementations to inspect HTTP transport level metadata that was present at
|
||||
* HTTP request processing time. This allows to extract custom headers and other
|
||||
* useful data for use during execution later on in the process.
|
||||
* @param contextExtractor The contextExtractor to fill in a
|
||||
* {@link McpTransportContext}.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if contextExtractor is null
|
||||
*/
|
||||
public Builder contextExtractor(McpTransportContextExtractor<ServerRequest> contextExtractor) {
|
||||
Assert.notNull(contextExtractor, "contextExtractor must not be null");
|
||||
this.contextExtractor = contextExtractor;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the security validator for validating HTTP requests.
|
||||
* @param securityValidator The security validator to use. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if securityValidator is null
|
||||
*/
|
||||
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
|
||||
Assert.notNull(securityValidator, "Security validator must not be null");
|
||||
this.securityValidator = securityValidator;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new instance of {@link WebFluxSseServerTransportProvider} with the
|
||||
* configured settings.
|
||||
* @return A new WebFluxSseServerTransportProvider instance
|
||||
* @throws IllegalStateException if required parameters are not set
|
||||
*/
|
||||
public WebFluxSseServerTransportProvider build() {
|
||||
Assert.notNull(messageEndpoint, "Message endpoint must be set");
|
||||
return new WebFluxSseServerTransportProvider(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
|
||||
baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025-2026 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import io.modelcontextprotocol.json.McpJsonDefaults;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpStatelessServerHandler;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
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;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Implementation of a WebFlux based {@link McpStatelessServerTransport}.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public class WebFluxStatelessServerTransport implements McpStatelessServerTransport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebFluxStatelessServerTransport.class);
|
||||
|
||||
private final McpJsonMapper jsonMapper;
|
||||
|
||||
private final String mcpEndpoint;
|
||||
|
||||
private final RouterFunction<?> routerFunction;
|
||||
|
||||
private McpStatelessServerHandler mcpHandler;
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor;
|
||||
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
/**
|
||||
* Security validator for validating HTTP requests.
|
||||
*/
|
||||
private final ServerTransportSecurityValidator securityValidator;
|
||||
|
||||
private WebFluxStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint,
|
||||
McpTransportContextExtractor<ServerRequest> contextExtractor,
|
||||
ServerTransportSecurityValidator securityValidator) {
|
||||
Assert.notNull(jsonMapper, "jsonMapper must not be null");
|
||||
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
|
||||
Assert.notNull(contextExtractor, "contextExtractor must not be null");
|
||||
Assert.notNull(securityValidator, "Security validator must not be null");
|
||||
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.mcpEndpoint = mcpEndpoint;
|
||||
this.contextExtractor = contextExtractor;
|
||||
this.securityValidator = securityValidator;
|
||||
this.routerFunction = RouterFunctions.route()
|
||||
.GET(this.mcpEndpoint, this::handleGet)
|
||||
.POST(this.mcpEndpoint, this::handlePost)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMcpHandler(McpStatelessServerHandler mcpHandler) {
|
||||
this.mcpHandler = mcpHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(() -> this.isClosing = true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebFlux router function that defines the transport's HTTP endpoints.
|
||||
* This router function should be integrated into the application's web configuration.
|
||||
*
|
||||
* <p>
|
||||
* The router function defines one endpoint handling two HTTP methods:
|
||||
* <ul>
|
||||
* <li>GET {messageEndpoint} - Unsupported, returns 405 METHOD NOT ALLOWED</li>
|
||||
* <li>POST {messageEndpoint} - For handling client requests and notifications</li>
|
||||
* </ul>
|
||||
* @return The configured {@link RouterFunction} for handling HTTP requests
|
||||
*/
|
||||
public RouterFunction<?> getRouterFunction() {
|
||||
return this.routerFunction;
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> handleGet(ServerRequest request) {
|
||||
return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> handlePost(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, List<String>> headers = request.headers().asHttpHeaders();
|
||||
this.securityValidator.validateHeaders(headers);
|
||||
}
|
||||
catch (ServerTransportSecurityException e) {
|
||||
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request);
|
||||
|
||||
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
|
||||
if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON)
|
||||
&& acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) {
|
||||
return ServerResponse.badRequest().build();
|
||||
}
|
||||
|
||||
return request.bodyToMono(String.class).<ServerResponse>flatMap(body -> {
|
||||
try {
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
|
||||
|
||||
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
|
||||
return this.mcpHandler.handleRequest(transportContext, jsonrpcRequest).flatMap(jsonrpcResponse -> {
|
||||
try {
|
||||
String json = jsonMapper.writeValueAsString(jsonrpcResponse);
|
||||
return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).bodyValue(json);
|
||||
}
|
||||
catch (IOException e) {
|
||||
logger.error("Failed to serialize response: {}", e.getMessage());
|
||||
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.bodyValue(new McpError("Failed to serialize response"));
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
|
||||
return this.mcpHandler.handleNotification(transportContext, jsonrpcNotification)
|
||||
.then(ServerResponse.accepted().build());
|
||||
}
|
||||
else {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(new McpError("The server accepts either requests or notifications"));
|
||||
}
|
||||
}
|
||||
catch (IllegalArgumentException | IOException e) {
|
||||
logger.error("Failed to deserialize message: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Invalid message format"));
|
||||
}
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a builder for the server.
|
||||
* @return a fresh {@link Builder} instance.
|
||||
*/
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for creating instances of {@link WebFluxStatelessServerTransport}.
|
||||
* <p>
|
||||
* This builder provides a fluent API for configuring and creating instances of
|
||||
* WebFluxSseServerTransportProvider with custom settings.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private McpJsonMapper jsonMapper;
|
||||
|
||||
private String mcpEndpoint = "/mcp";
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor = (
|
||||
serverRequest) -> McpTransportContext.EMPTY;
|
||||
|
||||
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
|
||||
|
||||
private Builder() {
|
||||
// used by a static method
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the JsonMapper to use for JSON serialization/deserialization of MCP
|
||||
* messages.
|
||||
* @param jsonMapper The JsonMapper instance. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if jsonMapper is null
|
||||
*/
|
||||
public Builder jsonMapper(McpJsonMapper jsonMapper) {
|
||||
Assert.notNull(jsonMapper, "JsonMapper must not be null");
|
||||
this.jsonMapper = jsonMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the endpoint URI where clients should send their JSON-RPC messages.
|
||||
* @param messageEndpoint The message endpoint URI. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if messageEndpoint is null
|
||||
*/
|
||||
public Builder messageEndpoint(String messageEndpoint) {
|
||||
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
|
||||
this.mcpEndpoint = messageEndpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the context extractor that allows providing the MCP feature
|
||||
* implementations to inspect HTTP transport level metadata that was present at
|
||||
* HTTP request processing time. This allows to extract custom headers and other
|
||||
* useful data for use during execution later on in the process.
|
||||
* @param contextExtractor The contextExtractor to fill in a
|
||||
* {@link McpTransportContext}.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if contextExtractor is null
|
||||
*/
|
||||
public Builder contextExtractor(McpTransportContextExtractor<ServerRequest> contextExtractor) {
|
||||
Assert.notNull(contextExtractor, "Context extractor must not be null");
|
||||
this.contextExtractor = contextExtractor;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the security validator for validating HTTP requests.
|
||||
* @param securityValidator The security validator to use. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if securityValidator is null
|
||||
*/
|
||||
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
|
||||
Assert.notNull(securityValidator, "Security validator must not be null");
|
||||
this.securityValidator = securityValidator;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new instance of {@link WebFluxStatelessServerTransport} with the
|
||||
* configured settings.
|
||||
* @return A new WebFluxSseServerTransportProvider instance
|
||||
* @throws IllegalStateException if required parameters are not set
|
||||
*/
|
||||
public WebFluxStatelessServerTransport build() {
|
||||
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
|
||||
return new WebFluxStatelessServerTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
|
||||
mcpEndpoint, contextExtractor, securityValidator);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,542 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025-2026 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import io.modelcontextprotocol.json.McpJsonDefaults;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.spec.HttpHeaders;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerSession;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.KeepAliveScheduler;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.Exceptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.FluxSink;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Implementation of a WebFlux based {@link McpStreamableServerTransportProvider}.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public class WebFluxStreamableServerTransportProvider implements McpStreamableServerTransportProvider {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebFluxStreamableServerTransportProvider.class);
|
||||
|
||||
public static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
private final McpJsonMapper jsonMapper;
|
||||
|
||||
private final String mcpEndpoint;
|
||||
|
||||
private final boolean disallowDelete;
|
||||
|
||||
private final RouterFunction<?> routerFunction;
|
||||
|
||||
private McpStreamableServerSession.Factory sessionFactory;
|
||||
|
||||
private final ConcurrentHashMap<String, McpStreamableServerSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor;
|
||||
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
private KeepAliveScheduler keepAliveScheduler;
|
||||
|
||||
/**
|
||||
* Security validator for validating HTTP requests.
|
||||
*/
|
||||
private final ServerTransportSecurityValidator securityValidator;
|
||||
|
||||
private WebFluxStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
|
||||
McpTransportContextExtractor<ServerRequest> contextExtractor, boolean disallowDelete,
|
||||
Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) {
|
||||
Assert.notNull(jsonMapper, "JsonMapper must not be null");
|
||||
Assert.notNull(mcpEndpoint, "Message endpoint must not be null");
|
||||
Assert.notNull(contextExtractor, "Context extractor must not be null");
|
||||
Assert.notNull(securityValidator, "Security validator must not be null");
|
||||
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.mcpEndpoint = mcpEndpoint;
|
||||
this.contextExtractor = contextExtractor;
|
||||
this.disallowDelete = disallowDelete;
|
||||
this.securityValidator = securityValidator;
|
||||
this.routerFunction = RouterFunctions.route()
|
||||
.GET(this.mcpEndpoint, this::handleGet)
|
||||
.POST(this.mcpEndpoint, this::handlePost)
|
||||
.DELETE(this.mcpEndpoint, this::handleDelete)
|
||||
.build();
|
||||
|
||||
if (keepAliveInterval != null) {
|
||||
this.keepAliveScheduler = KeepAliveScheduler
|
||||
.builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(this.sessions.values()))
|
||||
.initialDelay(keepAliveInterval)
|
||||
.interval(keepAliveInterval)
|
||||
.build();
|
||||
|
||||
this.keepAliveScheduler.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26,
|
||||
ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) {
|
||||
this.sessionFactory = sessionFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> notifyClients(String method, Object params) {
|
||||
if (sessions.isEmpty()) {
|
||||
logger.debug("No active sessions to broadcast message to");
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
logger.debug("Attempting to broadcast message to {} active sessions", sessions.size());
|
||||
|
||||
return Flux.fromIterable(sessions.values())
|
||||
.flatMap(session -> session.sendNotification(method, params)
|
||||
.doOnError(
|
||||
e -> logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()))
|
||||
.onErrorComplete())
|
||||
.then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.defer(() -> {
|
||||
this.isClosing = true;
|
||||
return Flux.fromIterable(sessions.values())
|
||||
.doFirst(() -> logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size()))
|
||||
.flatMap(McpStreamableServerSession::closeGracefully)
|
||||
.then();
|
||||
}).then().doOnSuccess(v -> {
|
||||
sessions.clear();
|
||||
if (this.keepAliveScheduler != null) {
|
||||
this.keepAliveScheduler.shutdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebFlux router function that defines the transport's HTTP endpoints.
|
||||
* This router function should be integrated into the application's web configuration.
|
||||
*
|
||||
* <p>
|
||||
* The router function defines one endpoint with three methods:
|
||||
* <ul>
|
||||
* <li>GET {messageEndpoint} - For the client listening SSE stream</li>
|
||||
* <li>POST {messageEndpoint} - For receiving client messages</li>
|
||||
* <li>DELETE {messageEndpoint} - For removing sessions</li>
|
||||
* </ul>
|
||||
* @return The configured {@link RouterFunction} for handling HTTP requests
|
||||
*/
|
||||
public RouterFunction<?> getRouterFunction() {
|
||||
return this.routerFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the listening SSE streams for clients.
|
||||
* @param request The incoming server request
|
||||
* @return A Mono which emits a response with the SSE event stream
|
||||
*/
|
||||
private Mono<ServerResponse> handleGet(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, List<String>> headers = request.headers().asHttpHeaders();
|
||||
this.securityValidator.validateHeaders(headers);
|
||||
}
|
||||
catch (ServerTransportSecurityException e) {
|
||||
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request);
|
||||
|
||||
return Mono.defer(() -> {
|
||||
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
|
||||
if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)) {
|
||||
return ServerResponse.badRequest().build();
|
||||
}
|
||||
|
||||
if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) {
|
||||
return ServerResponse.badRequest().build(); // TODO: say we need a session
|
||||
// id
|
||||
}
|
||||
|
||||
String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
|
||||
McpStreamableServerSession session = this.sessions.get(sessionId);
|
||||
|
||||
if (session == null) {
|
||||
return ServerResponse.notFound().build();
|
||||
}
|
||||
|
||||
if (!request.headers().header(HttpHeaders.LAST_EVENT_ID).isEmpty()) {
|
||||
String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID);
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.body(session.replay(lastId)
|
||||
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)),
|
||||
ServerSentEvent.class);
|
||||
}
|
||||
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.body(Flux.<ServerSentEvent<?>>create(sink -> {
|
||||
WebFluxStreamableMcpSessionTransport sessionTransport = new WebFluxStreamableMcpSessionTransport(
|
||||
sink);
|
||||
McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session
|
||||
.listeningStream(sessionTransport);
|
||||
sink.onDispose(listeningStream::close);
|
||||
// TODO Clarify why the outer context is not present in the
|
||||
// Flux.create sink?
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), ServerSentEvent.class);
|
||||
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming JSON-RPC messages from clients.
|
||||
* @param request The incoming server request containing the JSON-RPC message
|
||||
* @return A Mono with the response appropriate to a particular Streamable HTTP flow.
|
||||
*/
|
||||
private Mono<ServerResponse> handlePost(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, List<String>> headers = request.headers().asHttpHeaders();
|
||||
this.securityValidator.validateHeaders(headers);
|
||||
}
|
||||
catch (ServerTransportSecurityException e) {
|
||||
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request);
|
||||
|
||||
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
|
||||
if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON)
|
||||
&& acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) {
|
||||
return ServerResponse.badRequest().build();
|
||||
}
|
||||
|
||||
return request.bodyToMono(String.class).<ServerResponse>flatMap(body -> {
|
||||
try {
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body);
|
||||
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest
|
||||
&& jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) {
|
||||
var typeReference = new TypeRef<McpSchema.InitializeRequest>() {
|
||||
};
|
||||
McpSchema.InitializeRequest initializeRequest = jsonMapper.convertValue(jsonrpcRequest.params(),
|
||||
typeReference);
|
||||
McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory
|
||||
.startSession(initializeRequest);
|
||||
sessions.put(init.session().getId(), init.session());
|
||||
return init.initResult().map(initializeResult -> {
|
||||
McpSchema.JSONRPCResponse jsonrpcResponse = new McpSchema.JSONRPCResponse(
|
||||
McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initializeResult, null);
|
||||
try {
|
||||
return this.jsonMapper.writeValueAsString(jsonrpcResponse);
|
||||
}
|
||||
catch (IOException e) {
|
||||
logger.warn("Failed to serialize initResponse", e);
|
||||
throw Exceptions.propagate(e);
|
||||
}
|
||||
})
|
||||
.flatMap(initResult -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header(HttpHeaders.MCP_SESSION_ID, init.session().getId())
|
||||
.bodyValue(initResult));
|
||||
}
|
||||
|
||||
if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) {
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Session ID missing"));
|
||||
}
|
||||
|
||||
String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
McpStreamableServerSession session = sessions.get(sessionId);
|
||||
|
||||
if (session == null) {
|
||||
return ServerResponse.status(HttpStatus.NOT_FOUND)
|
||||
.bodyValue(new McpError("Session not found: " + sessionId));
|
||||
}
|
||||
|
||||
if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) {
|
||||
return session.accept(jsonrpcResponse).then(ServerResponse.accepted().build());
|
||||
}
|
||||
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
|
||||
return session.accept(jsonrpcNotification).then(ServerResponse.accepted().build());
|
||||
}
|
||||
else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.body(Flux.<ServerSentEvent<?>>create(sink -> {
|
||||
WebFluxStreamableMcpSessionTransport st = new WebFluxStreamableMcpSessionTransport(sink);
|
||||
Mono<Void> stream = session.responseStream(jsonrpcRequest, st);
|
||||
Disposable streamSubscription = stream.onErrorComplete(err -> {
|
||||
sink.error(err);
|
||||
return true;
|
||||
}).contextWrite(sink.contextView()).subscribe();
|
||||
sink.onCancel(streamSubscription);
|
||||
// TODO Clarify why the outer context is not present in the
|
||||
// Flux.create sink?
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)),
|
||||
ServerSentEvent.class);
|
||||
}
|
||||
else {
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Unknown message type"));
|
||||
}
|
||||
}
|
||||
catch (IllegalArgumentException | IOException e) {
|
||||
logger.error("Failed to deserialize message: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Invalid message format"));
|
||||
}
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.badRequest().build())
|
||||
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> handleDelete(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, List<String>> headers = request.headers().asHttpHeaders();
|
||||
this.securityValidator.validateHeaders(headers);
|
||||
}
|
||||
catch (ServerTransportSecurityException e) {
|
||||
return ServerResponse.status(e.getStatusCode()).bodyValue(e.getMessage());
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request);
|
||||
|
||||
return Mono.defer(() -> {
|
||||
if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) {
|
||||
return ServerResponse.badRequest().build(); // TODO: say we need a session
|
||||
// id
|
||||
}
|
||||
|
||||
if (this.disallowDelete) {
|
||||
return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build();
|
||||
}
|
||||
|
||||
String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
|
||||
McpStreamableServerSession session = this.sessions.get(sessionId);
|
||||
|
||||
if (session == null) {
|
||||
return ServerResponse.notFound().build();
|
||||
}
|
||||
|
||||
return session.delete().then(ServerResponse.ok().build());
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext));
|
||||
}
|
||||
|
||||
private class WebFluxStreamableMcpSessionTransport implements McpStreamableServerTransport {
|
||||
|
||||
private final FluxSink<ServerSentEvent<?>> sink;
|
||||
|
||||
public WebFluxStreamableMcpSessionTransport(FluxSink<ServerSentEvent<?>> sink) {
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
return this.sendMessage(message, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message, String messageId) {
|
||||
return Mono.fromSupplier(() -> {
|
||||
try {
|
||||
return jsonMapper.writeValueAsString(message);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw Exceptions.propagate(e);
|
||||
}
|
||||
}).doOnNext(jsonText -> {
|
||||
ServerSentEvent<Object> event = ServerSentEvent.builder()
|
||||
.id(messageId)
|
||||
.event(MESSAGE_EVENT_TYPE)
|
||||
.data(jsonText)
|
||||
.build();
|
||||
sink.next(event);
|
||||
}).doOnError(e -> {
|
||||
// TODO log with sessionid
|
||||
Throwable exception = Exceptions.unwrap(e);
|
||||
sink.error(exception);
|
||||
}).then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
|
||||
return jsonMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(sink::complete);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
sink.complete();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for creating instances of {@link WebFluxStreamableServerTransportProvider}.
|
||||
* <p>
|
||||
* This builder provides a fluent API for configuring and creating instances of
|
||||
* WebFluxStreamableServerTransportProvider with custom settings.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private McpJsonMapper jsonMapper;
|
||||
|
||||
private String mcpEndpoint = "/mcp";
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor = (
|
||||
serverRequest) -> McpTransportContext.EMPTY;
|
||||
|
||||
private boolean disallowDelete;
|
||||
|
||||
private Duration keepAliveInterval;
|
||||
|
||||
private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
|
||||
|
||||
private Builder() {
|
||||
// used by a static method
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link McpJsonMapper} to use for JSON serialization/deserialization of
|
||||
* MCP messages.
|
||||
* @param jsonMapper The {@link McpJsonMapper} instance. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if jsonMapper is null
|
||||
*/
|
||||
public Builder jsonMapper(McpJsonMapper jsonMapper) {
|
||||
Assert.notNull(jsonMapper, "McpJsonMapper must not be null");
|
||||
this.jsonMapper = jsonMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the endpoint URI where clients should send their JSON-RPC messages.
|
||||
* @param messageEndpoint The message endpoint URI. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if messageEndpoint is null
|
||||
*/
|
||||
public Builder messageEndpoint(String messageEndpoint) {
|
||||
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
|
||||
this.mcpEndpoint = messageEndpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the context extractor that allows providing the MCP feature
|
||||
* implementations to inspect HTTP transport level metadata that was present at
|
||||
* HTTP request processing time. This allows to extract custom headers and other
|
||||
* useful data for use during execution later on in the process.
|
||||
* @param contextExtractor The contextExtractor to fill in a
|
||||
* {@link McpTransportContext}.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if contextExtractor is null
|
||||
*/
|
||||
public Builder contextExtractor(McpTransportContextExtractor<ServerRequest> contextExtractor) {
|
||||
Assert.notNull(contextExtractor, "contextExtractor must not be null");
|
||||
this.contextExtractor = contextExtractor;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the session removal capability is disabled.
|
||||
* @param disallowDelete if {@code true}, the DELETE endpoint will not be
|
||||
* supported and sessions won't be deleted.
|
||||
* @return this builder instance
|
||||
*/
|
||||
public Builder disallowDelete(boolean disallowDelete) {
|
||||
this.disallowDelete = disallowDelete;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the keep-alive interval for the server transport.
|
||||
* @param keepAliveInterval The interval for sending keep-alive messages. If null,
|
||||
* no keep-alive will be scheduled.
|
||||
* @return this builder instance
|
||||
*/
|
||||
public Builder keepAliveInterval(Duration keepAliveInterval) {
|
||||
this.keepAliveInterval = keepAliveInterval;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the security validator for validating HTTP requests.
|
||||
* @param securityValidator The security validator to use. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if securityValidator is null
|
||||
*/
|
||||
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
|
||||
Assert.notNull(securityValidator, "Security validator must not be null");
|
||||
this.securityValidator = securityValidator;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new instance of {@link WebFluxStreamableServerTransportProvider} with
|
||||
* the configured settings.
|
||||
* @return A new WebFluxStreamableServerTransportProvider instance
|
||||
* @throws IllegalStateException if required parameters are not set
|
||||
*/
|
||||
public WebFluxStreamableServerTransportProvider build() {
|
||||
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
|
||||
return new WebFluxStreamableServerTransportProvider(
|
||||
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor,
|
||||
disallowDelete, keepAliveInterval, securityValidator);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
|
||||
import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
|
||||
import io.modelcontextprotocol.server.McpServer.SingleSessionSyncSpecification;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.server.TestUtil;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
@Timeout(15)
|
||||
class WebFluxSseIntegrationTests extends AbstractMcpClientServerIntegrationTests {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
private static final String CUSTOM_SSE_ENDPOINT = "/somePath/sse";
|
||||
|
||||
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
private WebFluxSseServerTransportProvider mcpServerTransportProvider;
|
||||
|
||||
static McpTransportContextExtractor<ServerRequest> TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext
|
||||
.create(Map.of("important", "value"));
|
||||
|
||||
static Stream<Arguments> clientsForTesting() {
|
||||
return Stream.of(Arguments.of("httpclient"), Arguments.of("webflux"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareClients(int port, String mcpEndpoint) {
|
||||
|
||||
clientBuilders
|
||||
.put("httpclient",
|
||||
McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT)
|
||||
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
|
||||
.build()).requestTimeout(Duration.ofHours(10)));
|
||||
|
||||
clientBuilders.put("webflux",
|
||||
McpClient
|
||||
.sync(WebFluxSseClientTransport.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
|
||||
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
|
||||
.build())
|
||||
.requestTimeout(Duration.ofHours(10)));
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AsyncSpecification<?> prepareAsyncServerBuilder() {
|
||||
return McpServer.async(mcpServerTransportProvider);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SingleSessionSyncSpecification prepareSyncServerBuilder() {
|
||||
return McpServer.sync(mcpServerTransportProvider);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
public void before() {
|
||||
|
||||
this.mcpServerTransportProvider = new WebFluxSseServerTransportProvider.Builder()
|
||||
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
|
||||
.contextExtractor(TEST_CONTEXT_EXTRACTOR)
|
||||
.build();
|
||||
|
||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpServerTransportProvider.getRouterFunction());
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
|
||||
prepareClients(PORT, null);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification;
|
||||
import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification;
|
||||
import io.modelcontextprotocol.server.TestUtil;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
@Timeout(15)
|
||||
class WebFluxStatelessIntegrationTests extends AbstractStatelessIntegrationTests {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
private WebFluxStatelessServerTransport mcpStreamableServerTransport;
|
||||
|
||||
static Stream<Arguments> clientsForTesting() {
|
||||
return Stream.of(Arguments.of("httpclient"), Arguments.of("webflux"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareClients(int port, String mcpEndpoint) {
|
||||
clientBuilders
|
||||
.put("httpclient",
|
||||
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
||||
.endpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.build()).initializationTimeout(Duration.ofHours(10)).requestTimeout(Duration.ofHours(10)));
|
||||
clientBuilders
|
||||
.put("webflux", McpClient
|
||||
.sync(WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
|
||||
.endpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.build())
|
||||
.initializationTimeout(Duration.ofHours(10))
|
||||
.requestTimeout(Duration.ofHours(10)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StatelessAsyncSpecification prepareAsyncServerBuilder() {
|
||||
return McpServer.async(this.mcpStreamableServerTransport);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected StatelessSyncSpecification prepareSyncServerBuilder() {
|
||||
return McpServer.sync(this.mcpStreamableServerTransport);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
public void before() {
|
||||
this.mcpStreamableServerTransport = WebFluxStatelessServerTransport.builder()
|
||||
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.build();
|
||||
|
||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpStreamableServerTransport.getRouterFunction());
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
|
||||
prepareClients(PORT, null);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures;
|
||||
import io.modelcontextprotocol.server.McpSyncServer;
|
||||
import io.modelcontextprotocol.server.McpSyncServerExchange;
|
||||
import io.modelcontextprotocol.server.TestUtil;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.utils.McpTestRequestRecordingExchangeFilterFunction;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class WebFluxStreamableHttpVersionNegotiationIntegrationTests {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
private final McpTestRequestRecordingExchangeFilterFunction recordingFilterFunction = new McpTestRequestRecordingExchangeFilterFunction();
|
||||
|
||||
private final McpSchema.Tool toolSpec = McpSchema.Tool.builder()
|
||||
.name("test-tool")
|
||||
.description("return the protocol version used")
|
||||
.build();
|
||||
|
||||
private final BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> toolHandler = (
|
||||
exchange, request) -> new McpSchema.CallToolResult(
|
||||
exchange.transportContext().get("protocol-version").toString(), null);
|
||||
|
||||
private final WebFluxStreamableServerTransportProvider mcpStreamableServerTransportProvider = WebFluxStreamableServerTransportProvider
|
||||
.builder()
|
||||
.contextExtractor(req -> McpTransportContext
|
||||
.create(Map.of("protocol-version", req.headers().firstHeader("MCP-protocol-version"))))
|
||||
.build();
|
||||
|
||||
private final McpSyncServer mcpServer = McpServer.sync(mcpStreamableServerTransportProvider)
|
||||
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build())
|
||||
.tools(new McpServerFeatures.SyncToolSpecification(toolSpec, null, toolHandler))
|
||||
.build();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RouterFunction<ServerResponse> filteredRouter = mcpStreamableServerTransportProvider.getRouterFunction()
|
||||
.filter(recordingFilterFunction);
|
||||
|
||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(filteredRouter);
|
||||
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
|
||||
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
if (mcpServer != null) {
|
||||
mcpServer.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesLatestVersion() {
|
||||
var client = McpClient
|
||||
.sync(WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
|
||||
.build())
|
||||
.requestTimeout(Duration.ofHours(10))
|
||||
.build();
|
||||
|
||||
client.initialize();
|
||||
|
||||
McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of()));
|
||||
|
||||
var calls = recordingFilterFunction.getCalls();
|
||||
assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\""))
|
||||
// GET /mcp ; POST notification/initialized ; POST tools/call
|
||||
.hasSize(3)
|
||||
.map(McpTestRequestRecordingExchangeFilterFunction.Call::headers)
|
||||
.allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version",
|
||||
ProtocolVersions.MCP_2025_11_25));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.content()).hasSize(1)
|
||||
.first()
|
||||
.extracting(McpSchema.TextContent.class::cast)
|
||||
.extracting(McpSchema.TextContent::text)
|
||||
.isEqualTo(ProtocolVersions.MCP_2025_11_25);
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesServerSupportedVersion() {
|
||||
var transport = WebClientStreamableHttpTransport
|
||||
.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
|
||||
.supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_11_25, "2263-03-18"))
|
||||
.build();
|
||||
var client = McpClient.sync(transport).requestTimeout(Duration.ofHours(10)).build();
|
||||
|
||||
client.initialize();
|
||||
|
||||
McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of()));
|
||||
|
||||
var calls = recordingFilterFunction.getCalls();
|
||||
// Initialize tells the server the Client's latest supported version
|
||||
// FIXME: Set the correct protocol version on GET /mcp
|
||||
assertThat(calls)
|
||||
.filteredOn(c -> !c.body().contains("\"method\":\"initialize\"") && c.method().equals(HttpMethod.POST))
|
||||
// POST notification/initialized ; POST tools/call
|
||||
.hasSize(2)
|
||||
.map(McpTestRequestRecordingExchangeFilterFunction.Call::headers)
|
||||
.allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version",
|
||||
ProtocolVersions.MCP_2025_11_25));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.content()).hasSize(1)
|
||||
.first()
|
||||
.extracting(McpSchema.TextContent.class::cast)
|
||||
.extracting(McpSchema.TextContent::text)
|
||||
.isEqualTo(ProtocolVersions.MCP_2025_11_25);
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
|
||||
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.server.TestUtil;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
@Timeout(15)
|
||||
class WebFluxStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
private WebFluxStreamableServerTransportProvider mcpStreamableServerTransportProvider;
|
||||
|
||||
static McpTransportContextExtractor<ServerRequest> TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext
|
||||
.create(Map.of("important", "value"));
|
||||
|
||||
static Stream<Arguments> clientsForTesting() {
|
||||
return Stream.of(Arguments.of("httpclient"), Arguments.of("webflux"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareClients(int port, String mcpEndpoint) {
|
||||
|
||||
clientBuilders
|
||||
.put("httpclient",
|
||||
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
||||
.endpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.build()).requestTimeout(Duration.ofHours(10)));
|
||||
clientBuilders.put("webflux",
|
||||
McpClient
|
||||
.sync(WebClientStreamableHttpTransport
|
||||
.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
|
||||
.endpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.build())
|
||||
.requestTimeout(Duration.ofHours(10)));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AsyncSpecification<?> prepareAsyncServerBuilder() {
|
||||
return McpServer.async(mcpStreamableServerTransportProvider);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SyncSpecification<?> prepareSyncServerBuilder() {
|
||||
return McpServer.sync(mcpStreamableServerTransportProvider);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
public void before() {
|
||||
|
||||
this.mcpStreamableServerTransportProvider = WebFluxStreamableServerTransportProvider.builder()
|
||||
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.contextExtractor(TEST_CONTEXT_EXTRACTOR)
|
||||
.build();
|
||||
|
||||
HttpHandler httpHandler = RouterFunctions
|
||||
.toHttpHandler(mcpStreamableServerTransportProvider.getRouterFunction());
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
|
||||
prepareClients(PORT, null);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
@Timeout(15)
|
||||
public class WebClientStreamableHttpAsyncClientResiliencyTests extends AbstractMcpAsyncClientResiliencyTests {
|
||||
|
||||
@Override
|
||||
protected McpClientTransport createMcpTransport() {
|
||||
return WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(host)).build();
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue