diff --git a/README.md b/README.md index c1f5f10c6..34133a796 100644 --- a/README.md +++ b/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. diff --git a/SECURITY.md b/SECURITY.md index 74e9880fd..502924200 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,21 @@ # Security Policy -Thank you for helping us keep the SDKs and systems they interact with secure. +Thank you for helping keep the Model Context Protocol and its ecosystem secure. ## Reporting Security Issues -This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model -Context Protocol project. +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. -The security of our systems and user data is Anthropic’s top priority. We appreciate the -work of security researchers acting in good faith in identifying and reporting potential -vulnerabilities. +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. -Our security program is managed on HackerOne and we ask that any validated vulnerability -in this functionality be reported through their -[submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). +## What to Include -## Vulnerability Disclosure Program +To help us triage and respond quickly, please include: -Our Vulnerability Program Guidelines are defined on our -[HackerOne program page](https://hackerone.com/anthropic-vdp). \ No newline at end of file +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) diff --git a/docs/blog/index.md b/docs/blog/index.md index 05761ac57..e61459078 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -1 +1 @@ -# Blog +# News diff --git a/docs/client.md b/docs/client.md index 29cfcc3b7..6a99928c5 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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); - ``` - -=== "SSE (HttpClient)" - - Creates a framework-agnostic (pure Java API) SSE client transport. Included in the core `mcp` module: +```java +ServerParameters params = ServerParameters.builder("npx") + .args("-y", "@modelcontextprotocol/server-everything", "dir") + .build(); +McpTransport transport = new StdioClientTransport(params); +``` - ```java - McpTransport transport = new HttpClientSseClientTransport("http://your-mcp-server"); - ``` +### Streamable HTTP -=== "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 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`): - Creates WebFlux-based SSE client transport. Requires the `mcp-spring-webflux` dependency: + ```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: diff --git a/docs/index.md b/docs/index.md index 71dcecfa1..e6062b5ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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: - -![MCP Stack Architecture](images/mcp-stack.svg) - -- **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. - -![Java MCP Client Architecture](images/java-mcp-client-architecture.jpg) - -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. - -![Java MCP Server Architecture](images/java-mcp-server-architecture.jpg) - -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 diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 000000000..9084b6a6a --- /dev/null +++ b/docs/overview.md @@ -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: + +![MCP Stack Architecture](images/mcp-stack.svg) + +- **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. + +![Java MCP Client Architecture](images/java-mcp-client-architecture.jpg) + +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. + +![Java MCP Server Architecture](images/java-mcp-server-architecture.jpg) + +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 + +
+ +- :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) + +
diff --git a/docs/quickstart.md b/docs/quickstart.md index 23cf2f75b..1ef69be04 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -44,22 +44,25 @@ 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`): ```xml - + - io.modelcontextprotocol.sdk + org.springframework.ai mcp-spring-webflux - + - io.modelcontextprotocol.sdk + org.springframework.ai mcp-spring-webmvc ``` + !!! 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. diff --git a/docs/server.md b/docs/server.md index 3c05aee30..0753726e2 100644 --- a/docs/server.md +++ b/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,9 +222,45 @@ The SDK includes several built-in transport provider implementations: } ``` -=== "SSE (WebFlux)" +### SSE HTTP (Legacy) + +=== "SSE Servlet" + + Creates a Servlet-based SSE server transport. Included in the core `mcp` module. + The `HttpServletSseServerTransportProvider` can be used with any Servlet container. + To use it with a Spring Web application, you can register it as a Servlet bean: + + ```java + @Configuration + @EnableWebMvc + public class McpServerConfig implements WebMvcConfigurer { + + @Bean + public HttpServletSseServerTransportProvider servletSseServerTransportProvider() { + return new HttpServletSseServerTransportProvider(new ObjectMapper(), "/mcp/message"); + } + + @Bean + public ServletRegistrationBean customServletBean( + HttpServletSseServerTransportProvider transportProvider) { + return new ServletRegistrationBean<>(transportProvider); + } + } + ``` + + Implements the MCP HTTP with SSE transport specification using the traditional Servlet API, providing: + + - Asynchronous message handling using Servlet 6.0 async support + - Session management for multiple client connections + - Two types of endpoints: + - SSE endpoint (`/sse`) for server-to-client events + - Message endpoint (configurable) for client-to-server requests + - Error handling and response formatting + - Graceful shutdown support + +=== "SSE WebFlux (external)" - Creates WebFlux-based SSE server transport. Requires the `mcp-spring-webflux` dependency: + 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 @@ -245,9 +284,9 @@ The SDK includes several built-in transport provider implementations: - Message routing and session management - Graceful shutdown capabilities -=== "SSE (WebMvc)" +=== "SSE WebMvc (external)" - Creates WebMvc-based SSE server transport. Requires the `mcp-spring-webmvc` dependency: + 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 @@ -273,39 +312,6 @@ The SDK includes several built-in transport provider implementations: - Support for traditional web applications - Synchronous operation handling -=== "SSE (Servlet)" - - Creates a Servlet-based SSE server transport. Included in the core `mcp` module. - The `HttpServletSseServerTransportProvider` can be used with any Servlet container. - To use it with a Spring Web application, you can register it as a Servlet bean: - - ```java - @Configuration - @EnableWebMvc - public class McpServerConfig implements WebMvcConfigurer { - - @Bean - public HttpServletSseServerTransportProvider servletSseServerTransportProvider() { - return new HttpServletSseServerTransportProvider(new ObjectMapper(), "/mcp/message"); - } - - @Bean - public ServletRegistrationBean customServletBean( - HttpServletSseServerTransportProvider transportProvider) { - return new ServletRegistrationBean<>(transportProvider); - } - } - ``` - - Implements the MCP HTTP with SSE transport specification using the traditional Servlet API, providing: - - - Asynchronous message handling using Servlet 6.0 async support - - Session management for multiple client connections - - Two types of endpoints: - - SSE endpoint (`/sse`) for server-to-client events - - Message endpoint (configurable) for client-to-server requests - - Error handling and response formatting - - Graceful shutdown support ## Server Capabilities diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index ce24f9b11..b43b703fa 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -54,20 +54,6 @@ ${project.version} - - - io.modelcontextprotocol.sdk - mcp-spring-webflux - ${project.version} - - - - - io.modelcontextprotocol.sdk - mcp-spring-webmvc - ${project.version} - - diff --git a/mcp-spring/mcp-spring-webflux/README.md b/mcp-spring/mcp-spring-webflux/README.md deleted file mode 100644 index e701e41e6..000000000 --- a/mcp-spring/mcp-spring-webflux/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# WebFlux SSE Transport - -```xml - - io.modelcontextprotocol.sdk - mcp-spring-webflux - -``` - -```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; - } - - // ... - -} -``` diff --git a/mcp-spring/mcp-spring-webflux/pom.xml b/mcp-spring/mcp-spring-webflux/pom.xml deleted file mode 100644 index 875ade2e9..000000000 --- a/mcp-spring/mcp-spring-webflux/pom.xml +++ /dev/null @@ -1,148 +0,0 @@ - - - 4.0.0 - - io.modelcontextprotocol.sdk - mcp-parent - 1.0.0-SNAPSHOT - ../../pom.xml - - mcp-spring-webflux - jar - WebFlux transports - WebFlux implementation for the SSE and Streamable Http Client and Server transports - https://github.com/modelcontextprotocol/java-sdk - - - https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git - - - - - - io.modelcontextprotocol.sdk - mcp-core - 1.0.0-SNAPSHOT - - - - io.modelcontextprotocol.sdk - mcp-test - 1.0.0-SNAPSHOT - test - - - - org.springframework - spring-webflux - ${springframework.version} - - - - io.modelcontextprotocol.sdk - mcp-json-jackson2 - 1.0.0-SNAPSHOT - test - - - - io.projectreactor.netty - reactor-netty-http - test - - - - - org.springframework - spring-context - ${springframework.version} - test - - - - org.springframework - spring-test - ${springframework.version} - test - - - - org.assertj - assertj-core - ${assert4j.version} - test - - - org.junit.jupiter - junit-jupiter-api - ${junit.version} - test - - - org.mockito - mockito-core - ${mockito.version} - test - - - net.bytebuddy - byte-buddy - ${byte-buddy.version} - test - - - io.projectreactor - reactor-test - test - - - org.testcontainers - junit-jupiter - ${testcontainers.version} - test - - - org.testcontainers - toxiproxy - ${toxiproxy.version} - test - - - - org.awaitility - awaitility - ${awaitility.version} - test - - - - ch.qos.logback - logback-classic - ${logback.version} - test - - - - org.junit.jupiter - junit-jupiter-params - ${junit.version} - test - - - - net.javacrumbs.json-unit - json-unit-assertj - ${json-unit-assertj.version} - test - - - - - - diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java deleted file mode 100644 index 18e9d8ecc..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ /dev/null @@ -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 - * 2025-03-26 version of the MCP specification. - * - *

- * 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. - *

- *

- * This implementation does not handle backwards compatibility with the "HTTP - * with SSE" transport. In order to communicate over the phased-out - * 2024-11-05 protocol, use {@link HttpClientSseClientTransport} or - * {@link WebFluxSseClientTransport}. - *

- * - * @author Dariusz Jędrzejczyk - * @see Streamable - * HTTP transport specification - */ -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> 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> activeSession = new AtomicReference<>(); - - private final AtomicReference, Mono>> handler = new AtomicReference<>(); - - private final AtomicReference> exceptionHandler = new AtomicReference<>(); - - private final List supportedProtocolVersions; - - private final String latestSupportedProtocolVersion; - - private WebClientStreamableHttpTransport(McpJsonMapper jsonMapper, WebClient.Builder webClientBuilder, - String endpoint, boolean resumableStreams, boolean openConnectionOnStartup, - List 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 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 connect(Function, Mono> 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 createTransportSession() { - Function> 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 createClosedSession(McpTransportSession existingSession) { - var existingSessionId = Optional.ofNullable(existingSession) - .filter(session -> !(session instanceof ClosedMcpTransportSession)) - .flatMap(McpTransportSession::sessionId) - .orElse(null); - return new ClosedMcpTransportSession<>(existingSessionId); - } - - @Override - public void setExceptionHandler(Consumer 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 handler = this.exceptionHandler.get(); - if (handler != null) { - handler.accept(t); - } - } - - @Override - public Mono closeGracefully() { - return Mono.defer(() -> { - logger.debug("Graceful close triggered"); - McpTransportSession currentSession = this.activeSession.getAndUpdate(this::createClosedSession); - if (currentSession != null) { - return Mono.from(currentSession.closeGracefully()); - } - return Mono.empty(); - }); - } - - private Mono reconnect(McpTransportStream 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 disposableRef = new AtomicReference<>(); - final McpTransportSession 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.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 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 disposableRef = new AtomicReference<>(); - final McpTransportSession 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 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 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 extractError(ClientResponse response, String sessionRepresentation) { - return response.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 eventStream(McpTransportStream stream, ClientResponse response) { - McpTransportStream 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 directResponseFlux(McpSchema.JSONRPCMessage sentMessage, - ClientResponse response) { - return response.bodyToMono(String.class).>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 newEventStream(ClientResponse response, String sessionRepresentation) { - McpTransportStream 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 unmarshalFrom(Object data, TypeRef typeRef) { - return this.jsonMapper.convertValue(data, typeRef); - } - - private Tuple2, Iterable> parse(ServerSentEvent 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 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. - *

- * 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 version - * negotiation specification - * @see Protocol - * Version Header - */ - public Builder supportedProtocolVersions(List 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); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java deleted file mode 100644 index 304a3435f..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java +++ /dev/null @@ -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. - * - *

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

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

- * The message flow follows these steps: - *

    - *
  1. The client establishes an SSE connection to the server's /sse endpoint
  2. - *
  3. The server sends an 'endpoint' event containing the URI for sending messages
  4. - *
- * - * This implementation uses {@link WebClient} for HTTP communications and supports JSON - * serialization/deserialization of messages. - * - * @author Christian Tzolov - * @see MCP - * HTTP with SSE Transport Specification - */ -public class 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> 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 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 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. - * - *

- * The connection process follows these steps: - *

    - *
  1. Establishes an SSE connection to the server's /sse endpoint
  2. - *
  3. Waits for the server to send an 'endpoint' event with the message posting - * URI
  4. - *
  5. Sets up message handling for incoming JSON-RPC messages
  6. - *
- * - *

- * 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 connect(Function, Mono> 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> events = eventStream(); - this.inboundSubscription = events.concatMap(event -> Mono.just(event).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. - * - *

- * 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 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> 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> 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 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. - * - *

- * 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 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 unmarshalFrom(Object data, TypeRef 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); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java deleted file mode 100644 index e950417d4..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java +++ /dev/null @@ -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. - * - *

- * Key features: - *

    - *
  • Implements the {@link McpServerTransportProvider} interface that allows managing - * {@link McpServerSession} instances and enabling their communication with the - * {@link McpServerTransport} abstraction.
  • - *
  • Uses WebFlux for non-blocking request handling and SSE support
  • - *
  • Maintains client sessions for reliable message delivery
  • - *
  • Supports graceful shutdown with session cleanup
  • - *
  • Thread-safe message broadcasting to multiple clients
  • - *
- * - *

- * The transport sets up two main endpoints: - *

    - *
  • SSE endpoint (/sse) - For establishing SSE connections with clients
  • - *
  • Message endpoint (configurable) - For receiving JSON-RPC messages from clients
  • - *
- * - *

- * 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 sessions = new ConcurrentHashMap<>(); - - private McpTransportContextExtractor 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 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 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. - * - *

- * The method: - *

    - *
  • Serializes the message to JSON
  • - *
  • Creates a server-sent event with the message data
  • - *
  • Attempts to send the event to all active sessions
  • - *
  • Tracks and reports any delivery failures
  • - *
- * @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 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 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. - * - *

- * The router function defines two endpoints: - *

    - *
  • GET {sseEndpoint} - For establishing SSE connections
  • - *
  • POST {messageEndpoint} - For receiving client messages
  • - *
- * @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 handleSseConnection(ServerRequest request) { - if (isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); - } - - try { - Map> 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.>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. - * - *

- * The handler: - *

    - *
  • Deserializes the incoming JSON-RPC message
  • - *
  • Passes it through the message handler chain
  • - *
  • Returns appropriate HTTP responses based on processing results
  • - *
  • Handles various error conditions with appropriate error responses
  • - *
- * @param request The incoming server request containing the JSON-RPC message - * @return A Mono emitting the response indicating the message processing result - */ - private Mono handleMessage(ServerRequest request) { - if (isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); - } - - try { - Map> 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> sink; - - public WebFluxMcpSessionTransport(FluxSink> sink) { - this.sink = sink; - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - return Mono.fromSupplier(() -> { - try { - return jsonMapper.writeValueAsString(message); - } - catch (IOException e) { - throw Exceptions.propagate(e); - } - }).doOnNext(jsonText -> { - ServerSentEvent 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 unmarshalFrom(Object data, TypeRef typeRef) { - return jsonMapper.convertValue(data, typeRef); - } - - @Override - public Mono 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}. - *

- * 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 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 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); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java deleted file mode 100644 index bbb0493e4..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java +++ /dev/null @@ -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 contextExtractor; - - private volatile boolean isClosing = false; - - /** - * Security validator for validating HTTP requests. - */ - private final ServerTransportSecurityValidator securityValidator; - - private WebFluxStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor 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 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. - * - *

- * The router function defines one endpoint handling two HTTP methods: - *

    - *
  • GET {messageEndpoint} - Unsupported, returns 405 METHOD NOT ALLOWED
  • - *
  • POST {messageEndpoint} - For handling client requests and notifications
  • - *
- * @return The configured {@link RouterFunction} for handling HTTP requests - */ - public RouterFunction getRouterFunction() { - return this.routerFunction; - } - - private Mono handleGet(ServerRequest request) { - return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build(); - } - - private Mono handlePost(ServerRequest request) { - if (isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); - } - - try { - Map> 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 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).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}. - *

- * 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 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 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); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java deleted file mode 100644 index 223c2f009..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java +++ /dev/null @@ -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 sessions = new ConcurrentHashMap<>(); - - private McpTransportContextExtractor 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 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 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 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 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. - * - *

- * The router function defines one endpoint with three methods: - *

    - *
  • GET {messageEndpoint} - For the client listening SSE stream
  • - *
  • POST {messageEndpoint} - For receiving client messages
  • - *
  • DELETE {messageEndpoint} - For removing sessions
  • - *
- * @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 handleGet(ServerRequest request) { - if (isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); - } - - try { - Map> 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 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.>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 handlePost(ServerRequest request) { - if (isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); - } - - try { - Map> 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 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).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 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.>create(sink -> { - WebFluxStreamableMcpSessionTransport st = new WebFluxStreamableMcpSessionTransport(sink); - Mono 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 handleDelete(ServerRequest request) { - if (isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); - } - - try { - Map> 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> sink; - - public WebFluxStreamableMcpSessionTransport(FluxSink> sink) { - this.sink = sink; - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - return this.sendMessage(message, null); - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { - return Mono.fromSupplier(() -> { - try { - return jsonMapper.writeValueAsString(message); - } - catch (IOException e) { - throw Exceptions.propagate(e); - } - }).doOnNext(jsonText -> { - ServerSentEvent 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 unmarshalFrom(Object data, TypeRef typeRef) { - return jsonMapper.convertValue(data, typeRef); - } - - @Override - public Mono 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}. - *

- * 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 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 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); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java deleted file mode 100644 index eb8abb90c..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java +++ /dev/null @@ -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 TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext - .create(Map.of("important", "value")); - - static Stream 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(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStatelessIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStatelessIntegrationTests.java deleted file mode 100644 index 96a786a9e..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStatelessIntegrationTests.java +++ /dev/null @@ -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 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(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java deleted file mode 100644 index 5edc56fb9..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableHttpVersionNegotiationIntegrationTests.java +++ /dev/null @@ -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 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 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(); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableIntegrationTests.java deleted file mode 100644 index 5ab651931..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxStreamableIntegrationTests.java +++ /dev/null @@ -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 TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext - .create(Map.of("important", "value")); - - static Stream 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(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientResiliencyTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientResiliencyTests.java deleted file mode 100644 index 191f10376..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientResiliencyTests.java +++ /dev/null @@ -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(); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientTests.java deleted file mode 100644 index cf4458506..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientTests.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Timeout; -import org.springframework.web.reactive.function.client.WebClient; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; - -import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport; -import io.modelcontextprotocol.spec.McpClientTransport; - -@Timeout(15) -public class WebClientStreamableHttpAsyncClientTests extends AbstractMcpAsyncClientTests { - - static String host = "http://localhost:3001"; - - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") - .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") - .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) - .withExposedPorts(3001) - .waitingFor(Wait.forHttp("/").forStatusCode(404)); - - @Override - protected McpClientTransport createMcpTransport() { - return WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(host)).build(); - } - - @BeforeAll - static void startContainer() { - container.start(); - int port = container.getMappedPort(3001); - host = "http://" + container.getHost() + ":" + port; - } - - @AfterAll - static void stopContainer() { - container.stop(); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpSyncClientTests.java deleted file mode 100644 index f47ba5277..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpSyncClientTests.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Timeout; -import org.springframework.web.reactive.function.client.WebClient; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; - -import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport; -import io.modelcontextprotocol.spec.McpClientTransport; - -@Timeout(15) -public class WebClientStreamableHttpSyncClientTests extends AbstractMcpSyncClientTests { - - static String host = "http://localhost:3001"; - - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") - .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") - .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) - .withExposedPorts(3001) - .waitingFor(Wait.forHttp("/").forStatusCode(404)); - - @Override - protected McpClientTransport createMcpTransport() { - return WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(host)).build(); - } - - @BeforeAll - static void startContainer() { - container.start(); - int port = container.getMappedPort(3001); - host = "http://" + container.getHost() + ":" + port; - } - - @AfterAll - static void stopContainer() { - container.stop(); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java deleted file mode 100644 index 72c0168d5..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -import java.time.Duration; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Timeout; -import org.springframework.web.reactive.function.client.WebClient; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; - -import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; -import io.modelcontextprotocol.spec.McpClientTransport; - -/** - * Tests for the {@link McpAsyncClient} with {@link WebFluxSseClientTransport}. - * - * @author Christian Tzolov - */ -@Timeout(15) // Giving extra time beyond the client timeout -class WebFluxSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { - - static String host = "http://localhost:3001"; - - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") - .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") - .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) - .withExposedPorts(3001) - .waitingFor(Wait.forHttp("/").forStatusCode(404).forPort(3001)); - - @Override - protected McpClientTransport createMcpTransport() { - return WebFluxSseClientTransport.builder(WebClient.builder().baseUrl(host)).build(); - } - - @BeforeAll - static void startContainer() { - container.start(); - int port = container.getMappedPort(3001); - host = "http://" + container.getHost() + ":" + port; - } - - @AfterAll - static void stopContainer() { - container.stop(); - } - - protected Duration getInitializationTimeout() { - return Duration.ofSeconds(1); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java deleted file mode 100644 index b483029e0..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -import java.time.Duration; - -import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; -import io.modelcontextprotocol.spec.McpClientTransport; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Timeout; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import org.springframework.web.reactive.function.client.WebClient; - -/** - * Tests for the {@link McpSyncClient} with {@link WebFluxSseClientTransport}. - * - * @author Christian Tzolov - */ -@Timeout(15) // Giving extra time beyond the client timeout -class WebFluxSseMcpSyncClientTests extends AbstractMcpSyncClientTests { - - static String host = "http://localhost:3001"; - - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") - .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") - .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) - .withExposedPorts(3001) - .waitingFor(Wait.forHttp("/").forStatusCode(404)); - - @Override - protected McpClientTransport createMcpTransport() { - return WebFluxSseClientTransport.builder(WebClient.builder().baseUrl(host)).build(); - } - - @BeforeAll - static void startContainer() { - container.start(); - int port = container.getMappedPort(3001); - host = "http://" + container.getHost() + ":" + port; - } - - @AfterAll - static void stopContainer() { - container.stop(); - } - - protected Duration getInitializationTimeout() { - return Duration.ofSeconds(1); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportErrorHandlingTest.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportErrorHandlingTest.java deleted file mode 100644 index 214fa489b..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportErrorHandlingTest.java +++ /dev/null @@ -1,403 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package io.modelcontextprotocol.client.transport; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.timeout; -import static org.mockito.Mockito.verify; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import org.springframework.web.reactive.function.client.WebClient; - -import com.sun.net.httpserver.HttpServer; - -import io.modelcontextprotocol.server.TestUtil; -import io.modelcontextprotocol.spec.HttpHeaders; -import io.modelcontextprotocol.spec.McpClientTransport; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpTransportException; -import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException; -import io.modelcontextprotocol.spec.ProtocolVersions; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -/** - * Tests for error handling in WebClientStreamableHttpTransport. Addresses concurrency - * issues with proper Reactor patterns. - * - * @author Christian Tzolov - */ -@Timeout(15) -public class WebClientStreamableHttpTransportErrorHandlingTest { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String HOST = "http://localhost:" + PORT; - - private HttpServer server; - - private AtomicReference serverResponseStatus = new AtomicReference<>(200); - - private AtomicReference currentServerSessionId = new AtomicReference<>(null); - - private AtomicReference lastReceivedSessionId = new AtomicReference<>(null); - - private McpClientTransport transport; - - // Initialize latches for proper request synchronization - CountDownLatch firstRequestLatch; - - CountDownLatch secondRequestLatch; - - CountDownLatch getRequestLatch; - - @BeforeEach - void startServer() throws IOException { - - // Initialize latches for proper synchronization - firstRequestLatch = new CountDownLatch(1); - secondRequestLatch = new CountDownLatch(1); - getRequestLatch = new CountDownLatch(1); - - server = HttpServer.create(new InetSocketAddress(PORT), 0); - - // Configure the /mcp endpoint with dynamic response - server.createContext("/mcp", exchange -> { - String method = exchange.getRequestMethod(); - - if ("GET".equals(method)) { - // This is the SSE connection attempt after session establishment - getRequestLatch.countDown(); - // Return 405 Method Not Allowed to indicate SSE not supported - exchange.sendResponseHeaders(405, 0); - exchange.close(); - return; - } - - String requestSessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); - lastReceivedSessionId.set(requestSessionId); - - int status = serverResponseStatus.get(); - - // Track which request this is - if (firstRequestLatch.getCount() > 0) { - // // First request - should have no session ID - firstRequestLatch.countDown(); - } - else if (secondRequestLatch.getCount() > 0) { - // Second request - should have session ID - secondRequestLatch.countDown(); - } - - exchange.getResponseHeaders().set("Content-Type", "application/json"); - - // Don't include session ID in 404 and 400 responses - the implementation - // checks if the transport has a session stored locally - String responseSessionId = currentServerSessionId.get(); - if (responseSessionId != null && status == 200) { - exchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId); - } - if (status == 200) { - String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}"; - exchange.sendResponseHeaders(200, response.length()); - exchange.getResponseBody().write(response.getBytes()); - } - else { - exchange.sendResponseHeaders(status, 0); - } - exchange.close(); - }); - - server.setExecutor(null); - server.start(); - - transport = WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(HOST)).build(); - } - - @AfterEach - void stopServer() { - if (server != null) { - server.stop(0); - } - StepVerifier.create(transport.closeGracefully()).verifyComplete(); - } - - /** - * Test that 404 response WITHOUT session ID throws McpTransportException (not - * SessionNotFoundException) - */ - @Test - void test404WithoutSessionId() { - serverResponseStatus.set(404); - currentServerSessionId.set(null); // No session ID in response - - var testMessage = createTestMessage(); - - StepVerifier.create(transport.sendMessage(testMessage)) - .expectErrorMatches(throwable -> throwable instanceof McpTransportException - && throwable.getMessage().contains("Not Found") && throwable.getMessage().contains("404") - && !(throwable instanceof McpTransportSessionNotFoundException)) - .verify(Duration.ofSeconds(5)); - } - - /** - * Test that 404 response WITH session ID throws McpTransportSessionNotFoundException - * Fixed version using proper async coordination - */ - @Test - void test404WithSessionId() throws InterruptedException { - // First establish a session - serverResponseStatus.set(200); - currentServerSessionId.set("test-session-123"); - - // Set up exception handler to verify session invalidation - @SuppressWarnings("unchecked") - Consumer exceptionHandler = mock(Consumer.class); - transport.setExceptionHandler(exceptionHandler); - - // Connect with handler - StepVerifier.create(transport.connect(msg -> msg)).verifyComplete(); - - // Send initial message to establish session - var testMessage = createTestMessage(); - - // Send first message to establish session - StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); - - // Wait for first request to complete - assertThat(firstRequestLatch.await(5, TimeUnit.SECONDS)).isTrue(); - - // Wait for the GET request (SSE connection attempt) to complete - assertThat(getRequestLatch.await(5, TimeUnit.SECONDS)).isTrue(); - - // Now return 404 for next request - serverResponseStatus.set(404); - - // Use delaySubscription to ensure session is fully processed before next - // request - StepVerifier.create(Mono.delay(Duration.ofMillis(200)).then(transport.sendMessage(testMessage))) - .expectError(McpTransportSessionNotFoundException.class) - .verify(Duration.ofSeconds(5)); - - // Wait for second request to be made - assertThat(secondRequestLatch.await(5, TimeUnit.SECONDS)).isTrue(); - - // Verify the second request included the session ID - assertThat(lastReceivedSessionId.get()).isEqualTo("test-session-123"); - - // Verify exception handler was called with SessionNotFoundException using - // timeout - verify(exceptionHandler, timeout(5000)).accept(any(McpTransportSessionNotFoundException.class)); - } - - /** - * Test that 400 response WITHOUT session ID throws McpTransportException (not - * SessionNotFoundException) - */ - @Test - void test400WithoutSessionId() { - serverResponseStatus.set(400); - currentServerSessionId.set(null); // No session ID - - var testMessage = createTestMessage(); - - StepVerifier.create(transport.sendMessage(testMessage)) - .expectErrorMatches(throwable -> throwable instanceof McpTransportException - && throwable.getMessage().contains("Bad Request") && throwable.getMessage().contains("400") - && !(throwable instanceof McpTransportSessionNotFoundException)) - .verify(Duration.ofSeconds(5)); - } - - /** - * Test that 400 response WITH session ID throws McpTransportSessionNotFoundException - * Fixed version using proper async coordination - */ - @Test - void test400WithSessionId() throws InterruptedException { - - // First establish a session - serverResponseStatus.set(200); - currentServerSessionId.set("test-session-456"); - - // Set up exception handler - @SuppressWarnings("unchecked") - Consumer exceptionHandler = mock(Consumer.class); - transport.setExceptionHandler(exceptionHandler); - - // Connect with handler - StepVerifier.create(transport.connect(msg -> msg)).verifyComplete(); - - // Send initial message to establish session - var testMessage = createTestMessage(); - - // Send first message to establish session - StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); - - // Wait for first request to complete - boolean firstCompleted = firstRequestLatch.await(5, TimeUnit.SECONDS); - assertThat(firstCompleted).isTrue(); - - // Wait for the GET request (SSE connection attempt) to complete - boolean getCompleted = getRequestLatch.await(5, TimeUnit.SECONDS); - assertThat(getCompleted).isTrue(); - - // Now return 400 for next request (simulating unknown session ID) - serverResponseStatus.set(400); - - // Use delaySubscription to ensure session is fully processed before next - // request - StepVerifier.create(Mono.delay(Duration.ofMillis(200)).then(transport.sendMessage(testMessage))) - .expectError(McpTransportSessionNotFoundException.class) - .verify(Duration.ofSeconds(5)); - - // Wait for second request to be made - boolean secondCompleted = secondRequestLatch.await(5, TimeUnit.SECONDS); - assertThat(secondCompleted).isTrue(); - - // Verify the second request included the session ID - assertThat(lastReceivedSessionId.get()).isEqualTo("test-session-456"); - - // Verify exception handler was called with timeout - verify(exceptionHandler, timeout(5000)).accept(any(McpTransportSessionNotFoundException.class)); - } - - /** - * Test session recovery after SessionNotFoundException Fixed version using reactive - * patterns and proper synchronization - */ - @Test - void testSessionRecoveryAfter404() { - // First establish a session - serverResponseStatus.set(200); - currentServerSessionId.set("session-1"); - - // Send initial message to establish session - var testMessage = createTestMessage(); - - // Use Mono.defer to ensure proper sequencing - Mono establishSession = transport.sendMessage(testMessage).then(Mono.defer(() -> { - // Simulate session loss - return 404 - serverResponseStatus.set(404); - return transport.sendMessage(testMessage).onErrorResume(McpTransportSessionNotFoundException.class, e -> { - // Expected error, continue with recovery - return Mono.empty(); - }); - })).then(Mono.defer(() -> { - // Now server is back with new session - serverResponseStatus.set(200); - currentServerSessionId.set("session-2"); - lastReceivedSessionId.set(null); // Reset to verify new session - - // Should be able to establish new session - return transport.sendMessage(testMessage); - })).then(Mono.defer(() -> { - // Verify no session ID was sent (since old session was invalidated) - assertThat(lastReceivedSessionId.get()).isNull(); - - // Next request should use the new session ID - return transport.sendMessage(testMessage); - })).doOnSuccess(v -> { - // Session ID should now be sent with requests - assertThat(lastReceivedSessionId.get()).isEqualTo("session-2"); - }); - - StepVerifier.create(establishSession).verifyComplete(); - } - - /** - * Test that reconnect (GET request) also properly handles 404/400 errors Fixed - * version with proper async handling - */ - @Test - void testReconnectErrorHandling() throws InterruptedException { - // Initialize latch for SSE connection - CountDownLatch sseConnectionLatch = new CountDownLatch(1); - - // Set up SSE endpoint for GET requests - server.createContext("/mcp-sse", exchange -> { - String method = exchange.getRequestMethod(); - String requestSessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); - - if ("GET".equals(method)) { - sseConnectionLatch.countDown(); - int status = serverResponseStatus.get(); - - if (status == 404 && requestSessionId != null) { - // 404 with session ID - should trigger SessionNotFoundException - exchange.sendResponseHeaders(404, 0); - } - else if (status == 404) { - // 404 without session ID - should trigger McpTransportException - exchange.sendResponseHeaders(404, 0); - } - else { - // Normal SSE response - exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); - exchange.sendResponseHeaders(200, 0); - // Send a test SSE event - String sseData = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{}}\n\n"; - exchange.getResponseBody().write(sseData.getBytes()); - } - } - else { - // POST request handling - exchange.getResponseHeaders().set("Content-Type", "application/json"); - String responseSessionId = currentServerSessionId.get(); - if (responseSessionId != null) { - exchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId); - } - String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}"; - exchange.sendResponseHeaders(200, response.length()); - exchange.getResponseBody().write(response.getBytes()); - } - exchange.close(); - }); - - // Test with session ID - should get SessionNotFoundException - serverResponseStatus.set(200); - currentServerSessionId.set("sse-session-1"); - - var transport = WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(HOST)) - .endpoint("/mcp-sse") - .openConnectionOnStartup(true) // This will trigger GET request on connect - .build(); - - // First connect successfully - StepVerifier.create(transport.connect(msg -> msg)).verifyComplete(); - - // Wait for SSE connection to be established - boolean connected = sseConnectionLatch.await(5, TimeUnit.SECONDS); - assertThat(connected).isTrue(); - - // Send message to establish session - var testMessage = createTestMessage(); - StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); - - // Clean up - StepVerifier.create(transport.closeGracefully()).verifyComplete(); - } - - private McpSchema.JSONRPCRequest createTestMessage() { - var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26, - McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("Test Client", "1.0.0")); - return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id", - initializeRequest); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java deleted file mode 100644 index 34e422be4..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransportTest.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - */ -package io.modelcontextprotocol.client.transport; - -import io.modelcontextprotocol.spec.McpSchema; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import reactor.test.StepVerifier; - -import org.springframework.web.reactive.function.client.WebClient; - -class WebClientStreamableHttpTransportTest { - - static String host = "http://localhost:3001"; - - static WebClient.Builder builder; - - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") - .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 streamableHttp") - .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) - .withExposedPorts(3001) - .waitingFor(Wait.forHttp("/").forStatusCode(404)); - - @BeforeAll - static void startContainer() { - container.start(); - int port = container.getMappedPort(3001); - host = "http://" + container.getHost() + ":" + port; - builder = WebClient.builder().baseUrl(host); - } - - @AfterAll - static void stopContainer() { - container.stop(); - } - - @Test - void testCloseUninitialized() { - var transport = WebClientStreamableHttpTransport.builder(builder).build(); - - StepVerifier.create(transport.closeGracefully()).verifyComplete(); - - var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, - McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("MCP Client", "0.3.1")); - var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, - "test-id", initializeRequest); - - StepVerifier.create(transport.sendMessage(testMessage)) - .expectErrorMessage("MCP session has been closed") - .verify(); - } - - @Test - void testCloseInitialized() { - var transport = WebClientStreamableHttpTransport.builder(builder).build(); - - var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, - McpSchema.ClientCapabilities.builder().roots(true).build(), - new McpSchema.Implementation("MCP Client", "0.3.1")); - var testMessage = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, - "test-id", initializeRequest); - - StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); - StepVerifier.create(transport.closeGracefully()).verifyComplete(); - - StepVerifier.create(transport.sendMessage(testMessage)) - .expectErrorMatches(err -> err.getMessage().matches("MCP session with ID [a-zA-Z0-9-]* has been closed")) - .verify(); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java deleted file mode 100644 index 4b0d4e556..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransportTests.java +++ /dev/null @@ -1,371 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.client.transport; - -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; - -import com.fasterxml.jackson.databind.json.JsonMapper; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; -import reactor.test.StepVerifier; - -import org.springframework.http.codec.ServerSentEvent; -import org.springframework.web.reactive.function.client.WebClient; - -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Tests for the {@link WebFluxSseClientTransport} class. - * - * @author Christian Tzolov - */ -@Timeout(15) -class WebFluxSseClientTransportTests { - - static String host = "http://localhost:3001"; - - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") - .withCommand("npx -y @modelcontextprotocol/server-everything@2025.12.18 sse") - .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) - .withExposedPorts(3001) - .waitingFor(Wait.forHttp("/").forStatusCode(404)); - - private TestSseClientTransport transport; - - private WebClient.Builder webClientBuilder; - - // Test class to access protected methods - static class TestSseClientTransport extends WebFluxSseClientTransport { - - private final AtomicInteger inboundMessageCount = new AtomicInteger(0); - - private Sinks.Many> events = Sinks.many().unicast().onBackpressureBuffer(); - - public TestSseClientTransport(WebClient.Builder webClientBuilder, McpJsonMapper jsonMapper) { - super(webClientBuilder, jsonMapper); - } - - @Override - protected Flux> eventStream() { - return super.eventStream().mergeWith(events.asFlux()); - } - - public String getLastEndpoint() { - return messageEndpointSink.asMono().block(); - } - - public int getInboundMessageCount() { - return inboundMessageCount.get(); - } - - public void simulateSseComment(String comment) { - events.tryEmitNext(ServerSentEvent.builder().comment(comment).build()); - inboundMessageCount.incrementAndGet(); - } - - public void simulateEndpointEvent(String jsonMessage) { - events.tryEmitNext(ServerSentEvent.builder().event("endpoint").data(jsonMessage).build()); - inboundMessageCount.incrementAndGet(); - } - - public void simulateMessageEvent(String jsonMessage) { - events.tryEmitNext(ServerSentEvent.builder().event("message").data(jsonMessage).build()); - inboundMessageCount.incrementAndGet(); - } - - } - - @BeforeAll - static void startContainer() { - container.start(); - int port = container.getMappedPort(3001); - host = "http://" + container.getHost() + ":" + port; - } - - @AfterAll - static void cleanup() { - container.stop(); - } - - @BeforeEach - void setUp() { - webClientBuilder = WebClient.builder().baseUrl(host); - transport = new TestSseClientTransport(webClientBuilder, JSON_MAPPER); - transport.connect(Function.identity()).block(); - } - - @AfterEach - void afterEach() { - if (transport != null) { - assertThatCode(() -> transport.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - } - - @Test - void testEndpointEventHandling() { - assertThat(transport.getLastEndpoint()).startsWith("/message?"); - } - - @Test - void constructorValidation() { - assertThatThrownBy(() -> new WebFluxSseClientTransport(null, JSON_MAPPER)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("WebClient.Builder must not be null"); - - assertThatThrownBy(() -> new WebFluxSseClientTransport(webClientBuilder, null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("jsonMapper must not be null"); - } - - @Test - void testBuilderPattern() { - // Test default builder - WebFluxSseClientTransport transport1 = WebFluxSseClientTransport.builder(webClientBuilder).build(); - assertThatCode(() -> transport1.closeGracefully().block()).doesNotThrowAnyException(); - - // Test builder with custom ObjectMapper - JsonMapper customMapper = JsonMapper.builder().build(); - WebFluxSseClientTransport transport2 = WebFluxSseClientTransport.builder(webClientBuilder) - .jsonMapper(new JacksonMcpJsonMapper(customMapper)) - .build(); - assertThatCode(() -> transport2.closeGracefully().block()).doesNotThrowAnyException(); - - // Test builder with custom SSE endpoint - WebFluxSseClientTransport transport3 = WebFluxSseClientTransport.builder(webClientBuilder) - .sseEndpoint("/custom-sse") - .build(); - assertThatCode(() -> transport3.closeGracefully().block()).doesNotThrowAnyException(); - - // Test builder with all custom parameters - WebFluxSseClientTransport transport4 = WebFluxSseClientTransport.builder(webClientBuilder) - .sseEndpoint("/custom-sse") - .build(); - assertThatCode(() -> transport4.closeGracefully().block()).doesNotThrowAnyException(); - } - - @Test - void testCommentSseMessage() { - // If the line starts with a character (:) are comment lins and should be ingored - // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation - - CopyOnWriteArrayList droppedErrors = new CopyOnWriteArrayList<>(); - reactor.core.publisher.Hooks.onErrorDropped(droppedErrors::add); - - try { - // Simulate receiving the SSE comment line - transport.simulateSseComment("sse comment"); - - StepVerifier.create(transport.closeGracefully()).verifyComplete(); - - assertThat(droppedErrors).hasSize(0); - } - finally { - reactor.core.publisher.Hooks.resetOnErrorDropped(); - } - } - - @Test - void testMessageProcessing() { - // Create a test message - JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); - - // Simulate receiving the message - transport.simulateMessageEvent(""" - { - "jsonrpc": "2.0", - "method": "test-method", - "id": "test-id", - "params": {"key": "value"} - } - """); - - // Subscribe to messages and verify - StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); - - assertThat(transport.getInboundMessageCount()).isEqualTo(1); - } - - @Test - void testResponseMessageProcessing() { - // Simulate receiving a response message - transport.simulateMessageEvent(""" - { - "jsonrpc": "2.0", - "id": "test-id", - "result": {"status": "success"} - } - """); - - // Create and send a request message - JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); - - // Verify message handling - StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); - - assertThat(transport.getInboundMessageCount()).isEqualTo(1); - } - - @Test - void testErrorMessageProcessing() { - // Simulate receiving an error message - transport.simulateMessageEvent(""" - { - "jsonrpc": "2.0", - "id": "test-id", - "error": { - "code": -32600, - "message": "Invalid Request" - } - } - """); - - // Create and send a request message - JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); - - // Verify message handling - StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); - - assertThat(transport.getInboundMessageCount()).isEqualTo(1); - } - - @Test - void testNotificationMessageProcessing() { - // Simulate receiving a notification message (no id) - transport.simulateMessageEvent(""" - { - "jsonrpc": "2.0", - "method": "update", - "params": {"status": "processing"} - } - """); - - // Verify the notification was processed - assertThat(transport.getInboundMessageCount()).isEqualTo(1); - } - - @Test - void testGracefulShutdown() { - // Test graceful shutdown - StepVerifier.create(transport.closeGracefully()).verifyComplete(); - - // Create a test message - JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", - Map.of("key", "value")); - - // Verify message is not processed after shutdown - StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete(); - - // Message count should remain 0 after shutdown - assertThat(transport.getInboundMessageCount()).isEqualTo(0); - } - - @Test - void testRetryBehavior() { - // Create a WebClient that simulates connection failures - WebClient.Builder failingWebClientBuilder = WebClient.builder().baseUrl("http://non-existent-host"); - - WebFluxSseClientTransport failingTransport = WebFluxSseClientTransport.builder(failingWebClientBuilder).build(); - - // Verify that the transport attempts to reconnect - StepVerifier.create(Mono.delay(Duration.ofSeconds(2))).expectNextCount(1).verifyComplete(); - - // Clean up - failingTransport.closeGracefully().block(); - } - - @Test - void testMultipleMessageProcessing() { - // Simulate receiving multiple messages in sequence - transport.simulateMessageEvent(""" - { - "jsonrpc": "2.0", - "method": "method1", - "id": "id1", - "params": {"key": "value1"} - } - """); - - transport.simulateMessageEvent(""" - { - "jsonrpc": "2.0", - "method": "method2", - "id": "id2", - "params": {"key": "value2"} - } - """); - - // Create and send corresponding messages - JSONRPCRequest message1 = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method1", "id1", - Map.of("key", "value1")); - - JSONRPCRequest message2 = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "method2", "id2", - Map.of("key", "value2")); - - // Verify both messages are processed - StepVerifier.create(transport.sendMessage(message1).then(transport.sendMessage(message2))).verifyComplete(); - - // Verify message count - assertThat(transport.getInboundMessageCount()).isEqualTo(2); - } - - @Test - void testMessageOrderPreservation() { - // Simulate receiving messages in a specific order - transport.simulateMessageEvent(""" - { - "jsonrpc": "2.0", - "method": "first", - "id": "1", - "params": {"sequence": 1} - } - """); - - transport.simulateMessageEvent(""" - { - "jsonrpc": "2.0", - "method": "second", - "id": "2", - "params": {"sequence": 2} - } - """); - - transport.simulateMessageEvent(""" - { - "jsonrpc": "2.0", - "method": "third", - "id": "3", - "params": {"sequence": 3} - } - """); - - // Verify message count and order - assertThat(transport.getInboundMessageCount()).isEqualTo(3); - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java deleted file mode 100644 index 3db0bbd3a..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - */ - -package io.modelcontextprotocol.common; - -import java.util.Map; -import java.util.function.BiFunction; - -import io.modelcontextprotocol.client.McpAsyncClient; -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport; -import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; -import io.modelcontextprotocol.server.McpAsyncServerExchange; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpStatelessServerFeatures; -import io.modelcontextprotocol.server.McpTransportContextExtractor; -import io.modelcontextprotocol.server.TestUtil; -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; -import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport; -import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpSchema; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import reactor.core.publisher.Mono; -import reactor.netty.DisposableServer; -import reactor.netty.http.server.HttpServer; -import reactor.test.StepVerifier; - -import org.springframework.http.server.reactive.HttpHandler; -import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ExchangeFilterFunction; -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.ServerRequest; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Integration tests for {@link McpTransportContext} propagation between MCP clients and - * async servers using Spring WebFlux infrastructure. - * - *

- * This test class validates the end-to-end flow of transport context propagation in MCP - * communication for asynchronous client and server implementations. It tests various - * combinations of client types and server transport mechanisms (stateless, streamable, - * SSE) to ensure proper context handling across different configurations. - * - *

Context Propagation Flow

- *
    - *
  1. Client sets a value in its transport context via thread-local Reactor context
  2. - *
  3. Client-side context provider extracts the value and adds it as an HTTP header to - * the request
  4. - *
  5. Server-side context extractor reads the header from the incoming request
  6. - *
  7. Server handler receives the extracted context and returns the value as the tool - * call result
  8. - *
  9. Test verifies the round-trip context propagation was successful
  10. - *
- * - * @author Daniel Garnier-Moiroux - * @author Christian Tzolov - */ -@Timeout(15) -public class AsyncServerMcpTransportContextIntegrationTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String HEADER_NAME = "x-test"; - - // Async client context provider - ExchangeFilterFunction asyncClientContextProvider = (request, next) -> Mono.deferContextual(ctx -> { - var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); - // // do stuff with the context - var headerValue = transportContext.get("client-side-header-value"); - if (headerValue == null) { - return next.exchange(request); - } - var reqWithHeader = ClientRequest.from(request).header(HEADER_NAME, headerValue.toString()).build(); - return next.exchange(reqWithHeader); - }); - - // Tools - private final McpSchema.Tool tool = McpSchema.Tool.builder() - .name("test-tool") - .description("return the value of the x-test header from call tool request") - .build(); - - private final BiFunction> asyncStatelessHandler = ( - transportContext, request) -> { - return Mono - .just(new McpSchema.CallToolResult(transportContext.get("server-side-header-value").toString(), null)); - }; - - private final BiFunction> asyncStatefulHandler = ( - exchange, request) -> { - return asyncStatelessHandler.apply(exchange.transportContext(), request); - }; - - // Server context extractor - private final McpTransportContextExtractor serverContextExtractor = (ServerRequest r) -> { - var headerValue = r.headers().firstHeader(HEADER_NAME); - return headerValue != null ? McpTransportContext.create(Map.of("server-side-header-value", headerValue)) - : McpTransportContext.EMPTY; - }; - - // Server transports - private final WebFluxStatelessServerTransport statelessServerTransport = WebFluxStatelessServerTransport.builder() - .contextExtractor(serverContextExtractor) - .build(); - - private final WebFluxStreamableServerTransportProvider streamableServerTransport = WebFluxStreamableServerTransportProvider - .builder() - .contextExtractor(serverContextExtractor) - .build(); - - private final WebFluxSseServerTransportProvider sseServerTransport = WebFluxSseServerTransportProvider.builder() - .contextExtractor(serverContextExtractor) - .messageEndpoint("/mcp/message") - .build(); - - // Async clients - private final McpAsyncClient asyncStreamableClient = McpClient - .async(WebClientStreamableHttpTransport - .builder(WebClient.builder().baseUrl("http://localhost:" + PORT).filter(asyncClientContextProvider)) - .build()) - .build(); - - private final McpAsyncClient asyncSseClient = McpClient - .async(WebFluxSseClientTransport - .builder(WebClient.builder().baseUrl("http://localhost:" + PORT).filter(asyncClientContextProvider)) - .build()) - .build(); - - private DisposableServer httpServer; - - @AfterEach - public void after() { - if (statelessServerTransport != null) { - statelessServerTransport.closeGracefully().block(); - } - if (streamableServerTransport != null) { - streamableServerTransport.closeGracefully().block(); - } - if (sseServerTransport != null) { - sseServerTransport.closeGracefully().block(); - } - if (asyncStreamableClient != null) { - asyncStreamableClient.closeGracefully().block(); - } - if (asyncSseClient != null) { - asyncSseClient.closeGracefully().block(); - } - stopHttpServer(); - } - - @Test - void asyncClientStatelessServer() { - - startHttpServer(statelessServerTransport.getRouterFunction()); - - var mcpServer = McpServer.async(statelessServerTransport) - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpStatelessServerFeatures.AsyncToolSpecification(tool, asyncStatelessHandler)) - .build(); - - StepVerifier.create(asyncStreamableClient.initialize()).assertNext(initResult -> { - assertThat(initResult).isNotNull(); - }).verifyComplete(); - - // Test tool call with context - StepVerifier - .create(asyncStreamableClient.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, - McpTransportContext.create(Map.of("client-side-header-value", "some important value"))))) - .assertNext(response -> { - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo("some important value"); - }) - .verifyComplete(); - - mcpServer.close(); - } - - @Test - void asyncClientStreamableServer() { - - startHttpServer(streamableServerTransport.getRouterFunction()); - - var mcpServer = McpServer.async(streamableServerTransport) - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.AsyncToolSpecification(tool, null, asyncStatefulHandler)) - .build(); - - StepVerifier.create(asyncStreamableClient.initialize()).assertNext(initResult -> { - assertThat(initResult).isNotNull(); - }).verifyComplete(); - - // Test tool call with context - StepVerifier - .create(asyncStreamableClient.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, - McpTransportContext.create(Map.of("client-side-header-value", "some important value"))))) - .assertNext(response -> { - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo("some important value"); - }) - .verifyComplete(); - - mcpServer.close(); - } - - @Test - void asyncClientSseServer() { - - startHttpServer(sseServerTransport.getRouterFunction()); - - var mcpServer = McpServer.async(sseServerTransport) - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.AsyncToolSpecification(tool, null, asyncStatefulHandler)) - .build(); - - StepVerifier.create(asyncSseClient.initialize()).assertNext(initResult -> { - assertThat(initResult).isNotNull(); - }).verifyComplete(); - - // Test tool call with context - StepVerifier - .create(asyncSseClient.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, - McpTransportContext.create(Map.of("client-side-header-value", "some important value"))))) - .assertNext(response -> { - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo("some important value"); - }) - .verifyComplete(); - - mcpServer.close(); - } - - private void startHttpServer(RouterFunction routerFunction) { - - HttpHandler httpHandler = RouterFunctions.toHttpHandler(routerFunction); - ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); - this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); - } - - private void stopHttpServer() { - if (httpServer != null) { - httpServer.disposeNow(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java deleted file mode 100644 index 94e16e73e..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - */ - -package io.modelcontextprotocol.common; - -import java.util.Map; -import java.util.function.BiFunction; -import java.util.function.Supplier; - -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.client.McpSyncClient; -import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport; -import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpStatelessServerFeatures; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.server.McpTransportContextExtractor; -import io.modelcontextprotocol.server.TestUtil; -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; -import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport; -import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpSchema; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import reactor.core.publisher.Mono; -import reactor.netty.DisposableServer; -import reactor.netty.http.server.HttpServer; - -import org.springframework.http.server.reactive.HttpHandler; -import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; -import org.springframework.web.reactive.function.client.ClientRequest; -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.ServerRequest; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Integration tests for {@link McpTransportContext} propagation between MCP client and - * server using synchronous operations in a Spring WebFlux environment. - *

- * This test class validates the end-to-end flow of transport context propagation across - * different WebFlux-based MCP transport implementations - * - *

- * The test scenario follows these steps: - *

    - *
  1. The client stores a value in a thread-local variable
  2. - *
  3. The client's transport context provider reads this value and includes it in the MCP - * context
  4. - *
  5. A WebClient filter extracts the context value and adds it as an HTTP header - * (x-test)
  6. - *
  7. The server's {@link McpTransportContextExtractor} reads the header from the - * request
  8. - *
  9. The server returns the header value as the tool call result, validating the - * round-trip
  10. - *
- * - *

- * This test demonstrates how custom context can be propagated through HTTP headers in a - * reactive WebFlux environment, enabling features like authentication tokens, correlation - * IDs, or other metadata to flow between MCP client and server. - * - * @author Daniel Garnier-Moiroux - * @author Christian Tzolov - * @since 1.0.0 - * @see McpTransportContext - * @see McpTransportContextExtractor - * @see WebFluxStatelessServerTransport - * @see WebFluxStreamableServerTransportProvider - * @see WebFluxSseServerTransportProvider - */ -@Timeout(15) -public class SyncServerMcpTransportContextIntegrationTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final ThreadLocal CLIENT_SIDE_HEADER_VALUE_HOLDER = new ThreadLocal<>(); - - private static final String HEADER_NAME = "x-test"; - - private final Supplier clientContextProvider = () -> { - var headerValue = CLIENT_SIDE_HEADER_VALUE_HOLDER.get(); - return headerValue != null ? McpTransportContext.create(Map.of("client-side-header-value", headerValue)) - : McpTransportContext.EMPTY; - }; - - private final BiFunction statelessHandler = ( - transportContext, request) -> { - return new McpSchema.CallToolResult(transportContext.get("server-side-header-value").toString(), null); - }; - - private final BiFunction statefulHandler = ( - exchange, request) -> statelessHandler.apply(exchange.transportContext(), request); - - private final McpTransportContextExtractor serverContextExtractor = (ServerRequest r) -> { - var headerValue = r.headers().firstHeader(HEADER_NAME); - return headerValue != null ? McpTransportContext.create(Map.of("server-side-header-value", headerValue)) - : McpTransportContext.EMPTY; - }; - - private final WebFluxStatelessServerTransport statelessServerTransport = WebFluxStatelessServerTransport.builder() - .contextExtractor(serverContextExtractor) - .build(); - - private final WebFluxStreamableServerTransportProvider streamableServerTransport = WebFluxStreamableServerTransportProvider - .builder() - .contextExtractor(serverContextExtractor) - .build(); - - private final WebFluxSseServerTransportProvider sseServerTransport = WebFluxSseServerTransportProvider.builder() - .contextExtractor(serverContextExtractor) - .messageEndpoint("/mcp/message") - .build(); - - private final McpSyncClient streamableClient = McpClient - .sync(WebClientStreamableHttpTransport.builder(WebClient.builder() - .baseUrl("http://localhost:" + PORT) - .filter((request, next) -> Mono.deferContextual(ctx -> { - var context = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); - // // do stuff with the context - var headerValue = context.get("client-side-header-value"); - if (headerValue == null) { - return next.exchange(request); - } - var reqWithHeader = ClientRequest.from(request).header(HEADER_NAME, headerValue.toString()).build(); - return next.exchange(reqWithHeader); - }))).build()) - .transportContextProvider(clientContextProvider) - .build(); - - private final McpSyncClient sseClient = McpClient.sync(WebFluxSseClientTransport.builder(WebClient.builder() - .baseUrl("http://localhost:" + PORT) - .filter((request, next) -> Mono.deferContextual(ctx -> { - var context = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); - // // do stuff with the context - var headerValue = context.get("client-side-header-value"); - if (headerValue == null) { - return next.exchange(request); - } - var reqWithHeader = ClientRequest.from(request).header(HEADER_NAME, headerValue.toString()).build(); - return next.exchange(reqWithHeader); - }))).build()).transportContextProvider(clientContextProvider).build(); - - private final McpSchema.Tool tool = McpSchema.Tool.builder() - .name("test-tool") - .description("return the value of the x-test header from call tool request") - .build(); - - private DisposableServer httpServer; - - @AfterEach - public void after() { - CLIENT_SIDE_HEADER_VALUE_HOLDER.remove(); - if (statelessServerTransport != null) { - statelessServerTransport.closeGracefully().block(); - } - if (streamableServerTransport != null) { - streamableServerTransport.closeGracefully().block(); - } - if (sseServerTransport != null) { - sseServerTransport.closeGracefully().block(); - } - if (streamableClient != null) { - streamableClient.closeGracefully(); - } - if (sseClient != null) { - sseClient.closeGracefully(); - } - stopHttpServer(); - } - - @Test - void statelessServer() { - - startHttpServer(statelessServerTransport.getRouterFunction()); - - var mcpServer = McpServer.sync(statelessServerTransport) - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpStatelessServerFeatures.SyncToolSpecification(tool, statelessHandler)) - .build(); - - McpSchema.InitializeResult initResult = streamableClient.initialize(); - assertThat(initResult).isNotNull(); - - CLIENT_SIDE_HEADER_VALUE_HOLDER.set("some important value"); - McpSchema.CallToolResult response = streamableClient - .callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo("some important value"); - - mcpServer.close(); - } - - @Test - void streamableServer() { - - startHttpServer(streamableServerTransport.getRouterFunction()); - - var mcpServer = McpServer.sync(streamableServerTransport) - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.SyncToolSpecification(tool, null, statefulHandler)) - .build(); - - McpSchema.InitializeResult initResult = streamableClient.initialize(); - assertThat(initResult).isNotNull(); - - CLIENT_SIDE_HEADER_VALUE_HOLDER.set("some important value"); - McpSchema.CallToolResult response = streamableClient - .callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo("some important value"); - - mcpServer.close(); - } - - @Test - void sseServer() { - startHttpServer(sseServerTransport.getRouterFunction()); - - var mcpServer = McpServer.sync(sseServerTransport) - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.SyncToolSpecification(tool, null, statefulHandler)) - .build(); - - McpSchema.InitializeResult initResult = sseClient.initialize(); - assertThat(initResult).isNotNull(); - - CLIENT_SIDE_HEADER_VALUE_HOLDER.set("some important value"); - McpSchema.CallToolResult response = sseClient.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo("some important value"); - - mcpServer.close(); - } - - private void startHttpServer(RouterFunction routerFunction) { - - HttpHandler httpHandler = RouterFunctions.toHttpHandler(routerFunction); - ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); - this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); - } - - private void stopHttpServer() { - if (httpServer != null) { - httpServer.disposeNow(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java deleted file mode 100644 index 3a5fba573..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/security/WebFluxServerTransportSecurityIntegrationTests.java +++ /dev/null @@ -1,332 +0,0 @@ -/* - * Copyright 2026-2026 the original author or authors. - */ - -package io.modelcontextprotocol.security; - -import java.time.Duration; -import java.util.stream.Stream; - -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.client.McpSyncClient; -import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport; -import io.modelcontextprotocol.client.transport.WebFluxSseClientTransport; -import io.modelcontextprotocol.json.McpJsonDefaults; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.TestUtil; -import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; -import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport; -import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpSchema; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.BeforeParameterizedClassInvocation; -import org.junit.jupiter.params.Parameter; -import org.junit.jupiter.params.ParameterizedClass; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import reactor.core.publisher.Mono; -import reactor.netty.DisposableServer; -import reactor.netty.http.server.HttpServer; - -import org.springframework.http.server.reactive.HttpHandler; -import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; -import org.springframework.web.reactive.function.client.ClientRequest; -import org.springframework.web.reactive.function.client.ClientResponse; -import org.springframework.web.reactive.function.client.ExchangeFilterFunction; -import org.springframework.web.reactive.function.client.ExchangeFunction; -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 static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Named.named; -import static org.junit.jupiter.params.provider.Arguments.arguments; - -/** - * Test the header security validation for all transport types. - * - * @author Daniel Garnier-Moiroux - */ -@ParameterizedClass -@MethodSource("transports") -public class WebFluxServerTransportSecurityIntegrationTests { - - private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; - - private static final String DISALLOWED_HOST = "malicious.example.com:8080"; - - @Parameter - private static Transport transport; - - private static DisposableServer httpServer; - - private static String baseUrl; - - @BeforeParameterizedClassInvocation - static void createTransportAndStartServer(Transport transport) { - var port = TestUtil.findAvailablePort(); - baseUrl = "http://localhost:" + port; - startServer(transport.routerFunction(), port); - } - - @AfterAll - static void afterAll() { - stopServer(); - } - - private McpSyncClient mcpClient; - - private final TestHeaderExchangeFilterFunction exchangeFilterFunction = new TestHeaderExchangeFilterFunction(); - - @BeforeEach - void setUp() { - mcpClient = transport.createMcpClient(baseUrl, exchangeFilterFunction); - } - - @AfterEach - void tearDown() { - mcpClient.close(); - } - - @Test - void originAllowed() { - exchangeFilterFunction.setOriginHeader(baseUrl); - var result = mcpClient.initialize(); - var tools = mcpClient.listTools(); - - assertThat(result.protocolVersion()).isNotEmpty(); - assertThat(tools.tools()).isEmpty(); - } - - @Test - void noOrigin() { - exchangeFilterFunction.setOriginHeader(null); - var result = mcpClient.initialize(); - var tools = mcpClient.listTools(); - - assertThat(result.protocolVersion()).isNotEmpty(); - assertThat(tools.tools()).isEmpty(); - } - - @Test - void connectOriginNotAllowed() { - exchangeFilterFunction.setOriginHeader(DISALLOWED_ORIGIN); - assertThatThrownBy(() -> mcpClient.initialize()); - } - - @Test - void messageOriginNotAllowed() { - exchangeFilterFunction.setOriginHeader(baseUrl); - mcpClient.initialize(); - exchangeFilterFunction.setOriginHeader(DISALLOWED_ORIGIN); - assertThatThrownBy(() -> mcpClient.listTools()); - } - - @Test - void hostAllowed() { - // Host header is set by default by WebClient to the request URI host - var result = mcpClient.initialize(); - var tools = mcpClient.listTools(); - - assertThat(result.protocolVersion()).isNotEmpty(); - assertThat(tools.tools()).isEmpty(); - } - - @Test - void connectHostNotAllowed() { - exchangeFilterFunction.setHostHeader(DISALLOWED_HOST); - assertThatThrownBy(() -> mcpClient.initialize()); - } - - @Test - void messageHostNotAllowed() { - mcpClient.initialize(); - exchangeFilterFunction.setHostHeader(DISALLOWED_HOST); - assertThatThrownBy(() -> mcpClient.listTools()); - } - - // ---------------------------------------------------- - // Server management - // ---------------------------------------------------- - - private static void startServer(RouterFunction routerFunction, int port) { - HttpHandler httpHandler = RouterFunctions.toHttpHandler(routerFunction); - ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); - httpServer = HttpServer.create().port(port).handle(adapter).bindNow(); - } - - private static void stopServer() { - if (httpServer != null) { - httpServer.disposeNow(); - } - } - - // ---------------------------------------------------- - // Transport servers to test - // ---------------------------------------------------- - - /** - * All transport types we want to test. We use a {@link MethodSource} rather than a - * {@link org.junit.jupiter.params.provider.ValueSource} to provide a readable name. - */ - static Stream transports() { - //@formatter:off - return Stream.of( - arguments(named("SSE", new Sse())), - arguments(named("Streamable HTTP", new StreamableHttp())), - arguments(named("Stateless", new Stateless())) - ); - //@formatter:on - } - - /** - * Represents a server transport we want to test, and how to create a client for the - * resulting MCP Server. - */ - interface Transport { - - McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction customizer); - - RouterFunction routerFunction(); - - } - - /** - * SSE-based transport. - */ - static class Sse implements Transport { - - private final WebFluxSseServerTransportProvider transportProvider; - - public Sse() { - transportProvider = WebFluxSseServerTransportProvider.builder() - .messageEndpoint("/mcp/message") - .securityValidator(DefaultServerTransportSecurityValidator.builder() - .allowedOrigin("http://localhost:*") - .allowedHost("localhost:*") - .build()) - .build(); - McpServer.sync(transportProvider) - .serverInfo("test-server", "1.0.0") - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .build(); - } - - @Override - public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) { - var transport = WebFluxSseClientTransport - .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) - .jsonMapper(McpJsonDefaults.getMapper()) - .build(); - return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); - } - - @Override - public RouterFunction routerFunction() { - return transportProvider.getRouterFunction(); - } - - } - - static class StreamableHttp implements Transport { - - private final WebFluxStreamableServerTransportProvider transportProvider; - - public StreamableHttp() { - transportProvider = WebFluxStreamableServerTransportProvider.builder() - .securityValidator(DefaultServerTransportSecurityValidator.builder() - .allowedOrigin("http://localhost:*") - .allowedHost("localhost:*") - .build()) - .build(); - McpServer.sync(transportProvider) - .serverInfo("test-server", "1.0.0") - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .build(); - } - - @Override - public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) { - var transport = WebClientStreamableHttpTransport - .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) - .jsonMapper(McpJsonDefaults.getMapper()) - .openConnectionOnStartup(true) - .build(); - return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); - } - - @Override - public RouterFunction routerFunction() { - return transportProvider.getRouterFunction(); - } - - } - - static class Stateless implements Transport { - - private final WebFluxStatelessServerTransport transportProvider; - - public Stateless() { - transportProvider = WebFluxStatelessServerTransport.builder() - .securityValidator(DefaultServerTransportSecurityValidator.builder() - .allowedOrigin("http://localhost:*") - .allowedHost("localhost:*") - .build()) - .build(); - McpServer.sync(transportProvider) - .serverInfo("test-server", "1.0.0") - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .build(); - } - - @Override - public McpSyncClient createMcpClient(String baseUrl, TestHeaderExchangeFilterFunction exchangeFilterFunction) { - var transport = WebClientStreamableHttpTransport - .builder(WebClient.builder().baseUrl(baseUrl).filter(exchangeFilterFunction)) - .jsonMapper(McpJsonDefaults.getMapper()) - .openConnectionOnStartup(true) - .build(); - return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); - } - - @Override - public RouterFunction routerFunction() { - return transportProvider.getRouterFunction(); - } - - } - - static class TestHeaderExchangeFilterFunction implements ExchangeFilterFunction { - - private String origin = null; - - private String host = null; - - public void setOriginHeader(String origin) { - this.origin = origin; - } - - public void setHostHeader(String host) { - this.host = host; - } - - @Override - public Mono filter(ClientRequest request, ExchangeFunction next) { - var builder = ClientRequest.from(request); - if (this.origin != null) { - builder.header("Origin", this.origin); - } - if (this.host != null) { - builder.header("Host", this.host); - } - return next.exchange(builder.build()); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java deleted file mode 100644 index fe0314687..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpAsyncServerTests.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; -import io.modelcontextprotocol.spec.McpServerTransportProvider; -import org.junit.jupiter.api.Timeout; -import reactor.netty.DisposableServer; -import reactor.netty.http.server.HttpServer; - -import org.springframework.http.server.reactive.HttpHandler; -import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; -import org.springframework.web.reactive.function.server.RouterFunctions; - -/** - * Tests for {@link McpAsyncServer} using {@link WebFluxSseServerTransportProvider}. - * - * @author Christian Tzolov - */ -@Timeout(15) // Giving extra time beyond the client timeout -class WebFluxSseMcpAsyncServerTests extends AbstractMcpAsyncServerTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private DisposableServer httpServer; - - private McpServerTransportProvider createMcpTransportProvider() { - var transportProvider = new WebFluxSseServerTransportProvider.Builder().messageEndpoint(MESSAGE_ENDPOINT) - .build(); - - HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction()); - ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); - httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); - return transportProvider; - } - - @Override - protected McpServer.AsyncSpecification prepareAsyncServerBuilder() { - return McpServer.async(createMcpTransportProvider()); - } - - @Override - protected void onStart() { - } - - @Override - protected void onClose() { - if (httpServer != null) { - httpServer.disposeNow(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java deleted file mode 100644 index 67ef90bdf..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxSseMcpSyncServerTests.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; -import io.modelcontextprotocol.spec.McpServerTransportProvider; -import org.junit.jupiter.api.Timeout; -import reactor.netty.DisposableServer; -import reactor.netty.http.server.HttpServer; - -import org.springframework.http.server.reactive.HttpHandler; -import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; -import org.springframework.web.reactive.function.server.RouterFunctions; - -/** - * Tests for {@link McpSyncServer} using {@link WebFluxSseServerTransportProvider}. - * - * @author Christian Tzolov - */ -@Timeout(15) // Giving extra time beyond the client timeout -class WebFluxSseMcpSyncServerTests extends AbstractMcpSyncServerTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private DisposableServer httpServer; - - private WebFluxSseServerTransportProvider transportProvider; - - @Override - protected McpServer.SyncSpecification prepareSyncServerBuilder() { - return McpServer.sync(createMcpTransportProvider()); - } - - private McpServerTransportProvider createMcpTransportProvider() { - transportProvider = new WebFluxSseServerTransportProvider.Builder().messageEndpoint(MESSAGE_ENDPOINT).build(); - return transportProvider; - } - - @Override - protected void onStart() { - HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction()); - ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); - httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); - } - - @Override - protected void onClose() { - if (httpServer != null) { - httpServer.disposeNow(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxStreamableMcpAsyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxStreamableMcpAsyncServerTests.java deleted file mode 100644 index 9b5a80f16..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxStreamableMcpAsyncServerTests.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; -import org.junit.jupiter.api.Timeout; -import org.springframework.http.server.reactive.HttpHandler; -import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; -import org.springframework.web.reactive.function.server.RouterFunctions; -import reactor.netty.DisposableServer; -import reactor.netty.http.server.HttpServer; - -/** - * Tests for {@link McpAsyncServer} using - * {@link WebFluxStreamableServerTransportProvider}. - * - * @author Christian Tzolov - * @author Dariusz Jędrzejczyk - */ -@Timeout(15) // Giving extra time beyond the client timeout -class WebFluxStreamableMcpAsyncServerTests extends AbstractMcpAsyncServerTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private DisposableServer httpServer; - - private McpStreamableServerTransportProvider createMcpTransportProvider() { - var transportProvider = WebFluxStreamableServerTransportProvider.builder() - .messageEndpoint(MESSAGE_ENDPOINT) - .build(); - - HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction()); - ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); - httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); - return transportProvider; - } - - @Override - protected McpServer.AsyncSpecification prepareAsyncServerBuilder() { - return McpServer.async(createMcpTransportProvider()); - } - - @Override - protected void onStart() { - } - - @Override - protected void onClose() { - if (httpServer != null) { - httpServer.disposeNow(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxStreamableMcpSyncServerTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxStreamableMcpSyncServerTests.java deleted file mode 100644 index 6a47ba3ae..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/WebFluxStreamableMcpSyncServerTests.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; -import org.junit.jupiter.api.Timeout; -import org.springframework.http.server.reactive.HttpHandler; -import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; -import org.springframework.web.reactive.function.server.RouterFunctions; -import reactor.netty.DisposableServer; -import reactor.netty.http.server.HttpServer; - -/** - * Tests for {@link McpAsyncServer} using - * {@link WebFluxStreamableServerTransportProvider}. - * - * @author Christian Tzolov - * @author Dariusz Jędrzejczyk - */ -@Timeout(15) // Giving extra time beyond the client timeout -class WebFluxStreamableMcpSyncServerTests extends AbstractMcpSyncServerTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private DisposableServer httpServer; - - private McpStreamableServerTransportProvider createMcpTransportProvider() { - var transportProvider = WebFluxStreamableServerTransportProvider.builder() - .messageEndpoint(MESSAGE_ENDPOINT) - .build(); - - HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction()); - ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); - httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow(); - return transportProvider; - } - - @Override - protected McpServer.SyncSpecification prepareSyncServerBuilder() { - return McpServer.sync(createMcpTransportProvider()); - } - - @Override - protected void onStart() { - } - - @Override - protected void onClose() { - if (httpServer != null) { - httpServer.disposeNow(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/transport/BlockingInputStream.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/transport/BlockingInputStream.java deleted file mode 100644 index dfb004e9b..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/server/transport/BlockingInputStream.java +++ /dev/null @@ -1,70 +0,0 @@ -/* -* Copyright 2024 - 2024 the original author or authors. -*/ - -package io.modelcontextprotocol.server.transport; - -import java.io.IOException; -import java.io.InputStream; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; - -public class BlockingInputStream extends InputStream { - - private final BlockingQueue queue = new LinkedBlockingQueue<>(); - - private volatile boolean completed = false; - - private volatile boolean closed = false; - - @Override - public int read() throws IOException { - if (closed) { - throw new IOException("Stream is closed"); - } - - try { - Integer value = queue.poll(); - if (value == null) { - if (completed) { - return -1; - } - value = queue.take(); // Blocks until data is available - if (value == null && completed) { - return -1; - } - } - return value; - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Read interrupted", e); - } - } - - public void write(int b) { - if (!closed && !completed) { - queue.offer(b); - } - } - - public void write(byte[] data) { - if (!closed && !completed) { - for (byte b : data) { - queue.offer((int) b & 0xFF); - } - } - } - - public void complete() { - this.completed = true; - } - - @Override - public void close() { - this.closed = true; - this.completed = true; - this.queue.clear(); - } - -} \ No newline at end of file diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java deleted file mode 100644 index 05d789704..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.modelcontextprotocol.utils; - -import io.modelcontextprotocol.json.McpJsonDefaults; -import io.modelcontextprotocol.json.McpJsonMapper; - -public final class McpJsonMapperUtils { - - private McpJsonMapperUtils() { - } - - public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); - -} \ No newline at end of file diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpTestRequestRecordingExchangeFilterFunction.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpTestRequestRecordingExchangeFilterFunction.java deleted file mode 100644 index 55129d481..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpTestRequestRecordingExchangeFilterFunction.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package io.modelcontextprotocol.utils; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import reactor.core.publisher.Mono; - -import org.springframework.http.HttpMethod; -import org.springframework.web.reactive.function.server.HandlerFilterFunction; -import org.springframework.web.reactive.function.server.HandlerFunction; -import org.springframework.web.reactive.function.server.ServerRequest; -import org.springframework.web.reactive.function.server.ServerResponse; - -/** - * Simple {@link HandlerFilterFunction} which records calls made to an MCP server. - * - * @author Daniel Garnier-Moiroux - */ -public class McpTestRequestRecordingExchangeFilterFunction implements HandlerFilterFunction { - - private final List calls = new ArrayList<>(); - - @Override - public Mono filter(ServerRequest request, HandlerFunction next) { - Map headers = request.headers() - .asHttpHeaders() - .keySet() - .stream() - .collect(Collectors.toMap(String::toLowerCase, k -> String.join(",", request.headers().header(k)))); - - var cr = request.bodyToMono(String.class).defaultIfEmpty("").map(body -> { - this.calls.add(new Call(request.method(), headers, body)); - return ServerRequest.from(request).body(body).build(); - }); - - return cr.flatMap(next::handle); - - } - - public List getCalls() { - return List.copyOf(calls); - } - - public record Call(HttpMethod method, Map headers, String body) { - - } - -} diff --git a/mcp-spring/mcp-spring-webflux/src/test/resources/logback.xml b/mcp-spring/mcp-spring-webflux/src/test/resources/logback.xml deleted file mode 100644 index abc831d13..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/resources/logback.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - - - - - - - diff --git a/mcp-spring/mcp-spring-webmvc/README.md b/mcp-spring/mcp-spring-webmvc/README.md deleted file mode 100644 index 9adf5b2ee..000000000 --- a/mcp-spring/mcp-spring-webmvc/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# WebMVC SSE Server Transport - -```xml - - io.modelcontextprotocol.sdk - mcp-spring-webmvc - -``` - - - -```java -String MESSAGE_ENDPOINT = "/mcp/message"; - -@Configuration -@EnableWebMvc -static class MyConfig { - - @Bean - public WebMvcSseServerTransport webMvcSseServerTransport() { - return new WebMvcSseServerTransport(new ObjectMapper(), MESSAGE_ENDPOINT); - } - - @Bean - public RouterFunction routerFunction(WebMvcSseServerTransport transport) { - return transport.getRouterFunction(); - } -} -``` diff --git a/mcp-spring/mcp-spring-webmvc/pom.xml b/mcp-spring/mcp-spring-webmvc/pom.xml deleted file mode 100644 index 34c0ced9e..000000000 --- a/mcp-spring/mcp-spring-webmvc/pom.xml +++ /dev/null @@ -1,156 +0,0 @@ - - - 4.0.0 - - io.modelcontextprotocol.sdk - mcp-parent - 1.0.0-SNAPSHOT - ../../pom.xml - - mcp-spring-webmvc - jar - Spring Web MVC transports - Web MVC implementation for the SSE and Streamable Http Server transports - https://github.com/modelcontextprotocol/java-sdk - - - https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git - - - - - - io.modelcontextprotocol.sdk - mcp-core - 1.0.0-SNAPSHOT - - - - org.springframework - spring-webmvc - ${springframework.version} - - - - io.modelcontextprotocol.sdk - mcp-test - 1.0.0-SNAPSHOT - test - - - - io.modelcontextprotocol.sdk - mcp-spring-webflux - 1.0.0-SNAPSHOT - test - - - - io.modelcontextprotocol.sdk - mcp-json-jackson2 - 1.0.0-SNAPSHOT - test - - - - - - org.springframework - spring-context - ${springframework.version} - test - - - - org.springframework - spring-test - ${springframework.version} - test - - - - org.assertj - assertj-core - ${assert4j.version} - test - - - org.junit.jupiter - junit-jupiter-api - ${junit.version} - test - - - org.mockito - mockito-core - ${mockito.version} - test - - - net.bytebuddy - byte-buddy - ${byte-buddy.version} - test - - - org.testcontainers - junit-jupiter - ${testcontainers.version} - test - - - - org.awaitility - awaitility - ${awaitility.version} - test - - - - ch.qos.logback - logback-classic - ${logback.version} - test - - - - io.projectreactor.netty - reactor-netty-http - test - - - io.projectreactor - reactor-test - test - - - jakarta.servlet - jakarta.servlet-api - ${jakarta.servlet.version} - provided - - - - org.apache.tomcat.embed - tomcat-embed-core - ${tomcat.version} - test - - - - net.javacrumbs.json-unit - json-unit-assertj - ${json-unit-assertj.version} - test - - - - - - diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java deleted file mode 100644 index e1eb67311..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java +++ /dev/null @@ -1,609 +0,0 @@ -/* - * Copyright 2024-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 java.util.concurrent.locks.ReentrantLock; - -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.publisher.Flux; -import reactor.core.publisher.Mono; - -import org.springframework.http.HttpStatus; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.RouterFunctions; -import org.springframework.web.servlet.function.ServerRequest; -import org.springframework.web.servlet.function.ServerResponse; -import org.springframework.web.servlet.function.ServerResponse.SseBuilder; -import org.springframework.web.util.UriComponentsBuilder; - -/** - * Server-side implementation of the Model Context Protocol (MCP) transport layer using - * HTTP with Server-Sent Events (SSE) through Spring WebMVC. This implementation provides - * a bridge between synchronous WebMVC operations and reactive programming patterns to - * maintain compatibility with the reactive transport interface. - * - *

- * Key features: - *

    - *
  • Implements bidirectional communication using HTTP POST for client-to-server - * messages and SSE for server-to-client messages
  • - *
  • Manages client sessions with unique IDs for reliable message delivery
  • - *
  • Supports graceful shutdown with proper session cleanup
  • - *
  • Provides JSON-RPC message handling through configured endpoints
  • - *
  • Includes built-in error handling and logging
  • - *
- * - *

- * The transport operates on two main endpoints: - *

    - *
  • {@code /sse} - The SSE endpoint where clients establish their event stream - * connection
  • - *
  • A configurable message endpoint where clients send their JSON-RPC messages via HTTP - * POST
  • - *
- * - *

- * This implementation uses {@link ConcurrentHashMap} to safely manage multiple client - * sessions in a thread-safe manner. Each client session is assigned a unique ID and - * maintains its own SSE connection. - * - * @author Christian Tzolov - * @author Alexandros Pappas - * @see McpServerTransportProvider - * @see RouterFunction - */ -public class WebMvcSseServerTransportProvider implements McpServerTransportProvider { - - private static final Logger logger = LoggerFactory.getLogger(WebMvcSseServerTransportProvider.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"; - - public static final String SESSION_ID = "sessionId"; - - /** - * Default SSE endpoint path as specified by the MCP transport specification. - */ - public static final String DEFAULT_SSE_ENDPOINT = "/sse"; - - private final McpJsonMapper jsonMapper; - - private final String messageEndpoint; - - private final String sseEndpoint; - - private final String baseUrl; - - private final RouterFunction routerFunction; - - private McpServerSession.Factory sessionFactory; - - /** - * Map of active client sessions, keyed by session ID. - */ - private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); - - private McpTransportContextExtractor contextExtractor; - - /** - * Flag indicating if the transport is shutting down. - */ - private volatile boolean isClosing = false; - - private KeepAliveScheduler keepAliveScheduler; - - /** - * Security validator for validating HTTP requests. - */ - private final ServerTransportSecurityValidator securityValidator; - - /** - * Constructs a new WebMvcSseServerTransportProvider instance. - * @param jsonMapper The McpJsonMapper to use for JSON serialization/deserialization - * of messages. - * @param baseUrl The base URL for the message endpoint, used to construct the full - * endpoint URL for clients. - * @param messageEndpoint The endpoint URI where clients should send their JSON-RPC - * messages via HTTP POST. This endpoint will be communicated to clients through the - * SSE connection's initial endpoint event. - * @param sseEndpoint The endpoint URI where clients establish their SSE connections. - * @param keepAliveInterval The interval for sending keep-alive messages to clients. - * @param contextExtractor The contextExtractor to fill in a - * {@link McpTransportContext}. - * @param securityValidator The security validator for validating HTTP requests. - * @throws IllegalArgumentException if any parameter is null - */ - private WebMvcSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint, - String sseEndpoint, Duration keepAliveInterval, - McpTransportContextExtractor contextExtractor, - ServerTransportSecurityValidator securityValidator) { - Assert.notNull(jsonMapper, "McpJsonMapper must not be null"); - Assert.notNull(baseUrl, "Message base URL 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 protocolVersions() { - return List.of(ProtocolVersions.MCP_2024_11_05); - } - - @Override - public void setSessionFactory(McpServerSession.Factory sessionFactory) { - this.sessionFactory = sessionFactory; - } - - /** - * Broadcasts a notification to all connected clients through their SSE connections. - * The message is serialized to JSON and sent as an SSE event with type "message". If - * any errors occur during sending to a particular client, they are logged but don't - * prevent sending to other clients. - * @param method The method name for the notification - * @param params The parameters for the notification - * @return A Mono that completes when the broadcast attempt is finished - */ - @Override - public Mono 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(); - } - - /** - * Initiates a graceful shutdown of the transport. This method: - *

    - *
  • Sets the closing flag to prevent new connections
  • - *
  • Closes all active SSE connections
  • - *
  • Removes all session records
  • - *
- * @return A Mono that completes when all cleanup operations are finished - */ - @Override - public Mono closeGracefully() { - return Flux.fromIterable(sessions.values()).doFirst(() -> { - this.isClosing = true; - 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 RouterFunction that defines the HTTP endpoints for this transport. The - * router function handles two endpoints: - *
    - *
  • GET /sse - For establishing SSE connections
  • - *
  • POST [messageEndpoint] - For receiving JSON-RPC messages from clients
  • - *
- * @return The configured RouterFunction for handling HTTP requests - */ - public RouterFunction getRouterFunction() { - return this.routerFunction; - } - - /** - * Handles new SSE connection requests from clients by creating a new session and - * establishing an SSE connection. This method: - *
    - *
  • Generates a unique session ID
  • - *
  • Creates a new session with a WebMvcMcpSessionTransport
  • - *
  • Sends an initial endpoint event to inform the client where to send - * messages
  • - *
  • Maintains the session in the sessions map
  • - *
- * @param request The incoming server request - * @return A ServerResponse configured for SSE communication, or an error response if - * the server is shutting down or the connection fails - */ - private ServerResponse handleSseConnection(ServerRequest request) { - if (this.isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); - } - - try { - Map> headers = request.headers().asHttpHeaders(); - this.securityValidator.validateHeaders(headers); - } - catch (ServerTransportSecurityException e) { - return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); - } - - // Send initial endpoint event - return ServerResponse.sse(sseBuilder -> { - WebMvcMcpSessionTransport sessionTransport = new WebMvcMcpSessionTransport(sseBuilder); - McpServerSession session = sessionFactory.create(sessionTransport); - String sessionId = session.getId(); - logger.debug("Creating new SSE connection for session: {}", sessionId); - sseBuilder.onComplete(() -> { - logger.debug("SSE connection completed for session: {}", sessionId); - sessions.remove(sessionId); - }); - sseBuilder.onTimeout(() -> { - logger.debug("SSE connection timed out for session: {}", sessionId); - sessions.remove(sessionId); - }); - this.sessions.put(sessionId, session); - - try { - sseBuilder.event(ENDPOINT_EVENT_TYPE).data(buildEndpointUrl(sessionId)); - } - catch (Exception e) { - logger.error("Failed to send initial endpoint event: {}", e.getMessage()); - this.sessions.remove(sessionId); - sseBuilder.error(e); - } - }, Duration.ZERO); - } - - /** - * 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. This method: - *
    - *
  • Deserializes the request body into a JSON-RPC message
  • - *
  • Processes the message through the session's handle method
  • - *
  • Returns appropriate HTTP responses based on the processing result
  • - *
- * @param request The incoming server request containing the JSON-RPC message - * @return A ServerResponse indicating success (200 OK) or appropriate error status - * with error details in case of failures - */ - private ServerResponse handleMessage(ServerRequest request) { - if (this.isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); - } - - try { - Map> headers = request.headers().asHttpHeaders(); - this.securityValidator.validateHeaders(headers); - } - catch (ServerTransportSecurityException e) { - return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); - } - - if (request.param(SESSION_ID).isEmpty()) { - return ServerResponse.badRequest().body(new McpError("Session ID missing in message endpoint")); - } - - String sessionId = request.param(SESSION_ID).get(); - McpServerSession session = sessions.get(sessionId); - - if (session == null) { - return ServerResponse.status(HttpStatus.NOT_FOUND).body(new McpError("Session not found: " + sessionId)); - } - - try { - final McpTransportContext transportContext = this.contextExtractor.extract(request); - - String body = request.body(String.class); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); - - // Process the message through the session's handle method - session.handle(message).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); // Block - // for - // WebMVC - // compatibility - - return ServerResponse.ok().build(); - } - catch (IllegalArgumentException | IOException e) { - logger.error("Failed to deserialize message: {}", e.getMessage()); - return ServerResponse.badRequest().body(new McpError("Invalid message format")); - } - catch (Exception e) { - logger.error("Error handling message: {}", e.getMessage()); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(e.getMessage())); - } - } - - /** - * Implementation of McpServerTransport for WebMVC SSE sessions. This class handles - * the transport-level communication for a specific client session. - */ - private class WebMvcMcpSessionTransport implements McpServerTransport { - - private final SseBuilder sseBuilder; - - /** - * Lock to ensure thread-safe access to the SSE builder when sending messages. - * This prevents concurrent modifications that could lead to corrupted SSE events. - */ - private final ReentrantLock sseBuilderLock = new ReentrantLock(); - - /** - * Creates a new session transport with the specified SSE builder. - * @param sseBuilder The SSE builder for sending server events to the client - */ - WebMvcMcpSessionTransport(SseBuilder sseBuilder) { - this.sseBuilder = sseBuilder; - } - - /** - * Sends a JSON-RPC message to the client through the SSE connection. - * @param message The JSON-RPC message to send - * @return A Mono that completes when the message has been sent - */ - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - return Mono.fromRunnable(() -> { - sseBuilderLock.lock(); - try { - String jsonText = jsonMapper.writeValueAsString(message); - sseBuilder.event(MESSAGE_EVENT_TYPE).data(jsonText); - } - catch (Exception e) { - logger.error("Failed to send message: {}", e.getMessage()); - sseBuilder.error(e); - } - finally { - sseBuilderLock.unlock(); - } - }); - } - - /** - * Converts data from one type to another using the configured McpJsonMapper. - * @param data The source data object to convert - * @param typeRef The target type reference - * @param The target type - * @return The converted object of type T - */ - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return jsonMapper.convertValue(data, typeRef); - } - - /** - * Initiates a graceful shutdown of the transport. - * @return A Mono that completes when the shutdown is complete - */ - @Override - public Mono closeGracefully() { - return Mono.fromRunnable(() -> { - sseBuilderLock.lock(); - try { - sseBuilder.complete(); - } - catch (Exception e) { - logger.warn("Failed to complete SSE builder: {}", e.getMessage()); - } - finally { - sseBuilderLock.unlock(); - } - }); - } - - /** - * Closes the transport immediately. - */ - @Override - public void close() { - sseBuilderLock.lock(); - try { - sseBuilder.complete(); - } - catch (Exception e) { - logger.warn("Failed to complete SSE builder: {}", e.getMessage()); - } - finally { - sseBuilderLock.unlock(); - } - } - - } - - /** - * Creates a new Builder instance for configuring and creating instances of - * WebMvcSseServerTransportProvider. - * @return A new Builder instance - */ - public static Builder builder() { - return new Builder(); - } - - /** - * Builder for creating instances of WebMvcSseServerTransportProvider. - *

- * This builder provides a fluent API for configuring and creating instances of - * WebMvcSseServerTransportProvider with custom settings. - */ - public static class Builder { - - private McpJsonMapper jsonMapper; - - private String baseUrl = ""; - - private String messageEndpoint; - - private String sseEndpoint = DEFAULT_SSE_ENDPOINT; - - private Duration keepAliveInterval; - - private McpTransportContextExtractor contextExtractor = ( - serverRequest) -> McpTransportContext.EMPTY; - - private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; - - /** - * Sets the JSON object mapper to use for message serialization/deserialization. - * @param jsonMapper The object mapper to use - * @return This builder instance for method chaining - */ - public Builder jsonMapper(McpJsonMapper jsonMapper) { - Assert.notNull(jsonMapper, "McpJsonMapper must not be null"); - this.jsonMapper = jsonMapper; - return this; - } - - /** - * Sets the base URL for the server transport. - * @param baseUrl The base URL to use - * @return This builder instance for method chaining - */ - public Builder baseUrl(String baseUrl) { - Assert.notNull(baseUrl, "Base URL must not be null"); - this.baseUrl = baseUrl; - return this; - } - - /** - * Sets the endpoint path where clients will send their messages. - * @param messageEndpoint The message endpoint path - * @return This builder instance for method chaining - */ - public Builder messageEndpoint(String messageEndpoint) { - Assert.hasText(messageEndpoint, "Message endpoint must not be empty"); - this.messageEndpoint = messageEndpoint; - return this; - } - - /** - * Sets the endpoint path where clients will establish SSE connections. - *

- * If not specified, the default value of {@link #DEFAULT_SSE_ENDPOINT} will be - * used. - * @param sseEndpoint The SSE endpoint path - * @return This builder instance for method chaining - */ - public Builder sseEndpoint(String sseEndpoint) { - Assert.hasText(sseEndpoint, "SSE endpoint must not be empty"); - this.sseEndpoint = sseEndpoint; - return this; - } - - /** - * Sets the interval for keep-alive pings. - *

- * If not specified, keep-alive pings will be disabled. - * @param keepAliveInterval The interval duration for keep-alive pings - * @return This builder instance for method chaining - */ - 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 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 WebMvcSseServerTransportProvider with the configured - * settings. - * @return A new WebMvcSseServerTransportProvider instance - * @throws IllegalStateException if jsonMapper or messageEndpoint is not set - */ - public WebMvcSseServerTransportProvider build() { - if (messageEndpoint == null) { - throw new IllegalStateException("MessageEndpoint must be set"); - } - return new WebMvcSseServerTransportProvider(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, - baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java deleted file mode 100644 index 2c379192c..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright 2024-2026 the original author or authors. - */ - -package io.modelcontextprotocol.server.transport; - -import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.json.McpJsonDefaults; -import io.modelcontextprotocol.json.McpJsonMapper; -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.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.RouterFunctions; -import org.springframework.web.servlet.function.ServerRequest; -import org.springframework.web.servlet.function.ServerResponse; -import reactor.core.publisher.Mono; - -import java.io.IOException; -import java.util.List; -import java.util.Map; - -/** - * Implementation of a WebMVC based {@link McpStatelessServerTransport}. - * - *

- * This is the non-reactive version of - * {@link io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport} - * - * @author Christian Tzolov - */ -public class WebMvcStatelessServerTransport implements McpStatelessServerTransport { - - private static final Logger logger = LoggerFactory.getLogger(WebMvcStatelessServerTransport.class); - - private final McpJsonMapper jsonMapper; - - private final String mcpEndpoint; - - private final RouterFunction routerFunction; - - private McpStatelessServerHandler mcpHandler; - - private McpTransportContextExtractor contextExtractor; - - private volatile boolean isClosing = false; - - /** - * Security validator for validating HTTP requests. - */ - private final ServerTransportSecurityValidator securityValidator; - - private WebMvcStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor 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 closeGracefully() { - return Mono.fromRunnable(() -> this.isClosing = true); - } - - /** - * Returns the WebMVC router function that defines the transport's HTTP endpoints. - * This router function should be integrated into the application's web configuration. - * - *

- * The router function defines one endpoint handling two HTTP methods: - *

    - *
  • GET {messageEndpoint} - Unsupported, returns 405 METHOD NOT ALLOWED
  • - *
  • POST {messageEndpoint} - For handling client requests and notifications
  • - *
- * @return The configured {@link RouterFunction} for handling HTTP requests - */ - public RouterFunction getRouterFunction() { - return this.routerFunction; - } - - private ServerResponse handleGet(ServerRequest request) { - return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build(); - } - - private ServerResponse handlePost(ServerRequest request) { - if (isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); - } - - try { - Map> headers = request.headers().asHttpHeaders(); - this.securityValidator.validateHeaders(headers); - } - catch (ServerTransportSecurityException e) { - return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); - } - - McpTransportContext transportContext = this.contextExtractor.extract(request); - - List acceptHeaders = request.headers().asHttpHeaders().getAccept(); - if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON) - && acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) { - return ServerResponse.badRequest().build(); - } - - try { - String body = request.body(String.class); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); - - if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { - try { - McpSchema.JSONRPCResponse jsonrpcResponse = this.mcpHandler - .handleRequest(transportContext, jsonrpcRequest) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - String json = jsonMapper.writeValueAsString(jsonrpcResponse); - return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(json); - } - catch (Exception e) { - logger.error("Failed to handle request: {}", e.getMessage()); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new McpError("Failed to handle request: " + e.getMessage())); - } - } - else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { - try { - this.mcpHandler.handleNotification(transportContext, jsonrpcNotification) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - return ServerResponse.accepted().build(); - } - catch (Exception e) { - logger.error("Failed to handle notification: {}", e.getMessage()); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new McpError("Failed to handle notification: " + e.getMessage())); - } - } - else { - return ServerResponse.badRequest() - .body(new McpError("The server accepts either requests or notifications")); - } - } - catch (IllegalArgumentException | IOException e) { - logger.error("Failed to deserialize message: {}", e.getMessage()); - return ServerResponse.badRequest().body(new McpError("Invalid message format")); - } - catch (Exception e) { - logger.error("Unexpected error handling message: {}", e.getMessage()); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new McpError("Unexpected error: " + e.getMessage())); - } - } - - /** - * 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 WebMvcStatelessServerTransport}. - *

- * This builder provides a fluent API for configuring and creating instances of - * WebMvcStatelessServerTransport with custom settings. - */ - public static class Builder { - - private McpJsonMapper jsonMapper; - - private String mcpEndpoint = "/mcp"; - - private McpTransportContextExtractor contextExtractor = ( - serverRequest) -> McpTransportContext.EMPTY; - - private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; - - private Builder() { - // used by a static method - } - - /** - * Sets the ObjectMapper to use for JSON serialization/deserialization of MCP - * messages. - * @param jsonMapper The ObjectMapper instance. Must not be null. - * @return this builder instance - * @throws IllegalArgumentException if jsonMapper is null - */ - public Builder jsonMapper(McpJsonMapper jsonMapper) { - Assert.notNull(jsonMapper, "ObjectMapper 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 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 WebMvcStatelessServerTransport} with the - * configured settings. - * @return A new WebMvcStatelessServerTransport instance - * @throws IllegalStateException if required parameters are not set - */ - public WebMvcStatelessServerTransport build() { - Assert.notNull(mcpEndpoint, "Message endpoint must be set"); - return new WebMvcStatelessServerTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, - mcpEndpoint, contextExtractor, securityValidator); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java b/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java deleted file mode 100644 index 4f701a9db..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java +++ /dev/null @@ -1,740 +0,0 @@ -/* - * Copyright 2024-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 java.util.concurrent.locks.ReentrantLock; - -import io.modelcontextprotocol.json.McpJsonDefaults; -import io.modelcontextprotocol.json.McpJsonMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.RouterFunctions; -import org.springframework.web.servlet.function.ServerRequest; -import org.springframework.web.servlet.function.ServerResponse; -import org.springframework.web.servlet.function.ServerResponse.SseBuilder; - -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 reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -/** - * Server-side implementation of the Model Context Protocol (MCP) streamable transport - * layer using HTTP with Server-Sent Events (SSE) through Spring WebMVC. This - * implementation provides a bridge between synchronous WebMVC operations and reactive - * programming patterns to maintain compatibility with the reactive transport interface. - * - *

- * This is the non-reactive version of - * {@link io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider} - * - * @author Christian Tzolov - * @author Dariusz Jędrzejczyk - * @see McpStreamableServerTransportProvider - * @see RouterFunction - */ -public class WebMvcStreamableServerTransportProvider implements McpStreamableServerTransportProvider { - - private static final Logger logger = LoggerFactory.getLogger(WebMvcStreamableServerTransportProvider.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"; - - /** - * Default base URL for the message endpoint. - */ - public static final String DEFAULT_BASE_URL = ""; - - /** - * The endpoint URI where clients should send their JSON-RPC messages. Defaults to - * "/mcp". - */ - private final String mcpEndpoint; - - /** - * Flag indicating whether DELETE requests are disallowed on the endpoint. - */ - private final boolean disallowDelete; - - private final McpJsonMapper jsonMapper; - - private final RouterFunction routerFunction; - - private McpStreamableServerSession.Factory sessionFactory; - - /** - * Map of active client sessions, keyed by mcp-session-id. - */ - private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); - - private McpTransportContextExtractor contextExtractor; - - /** - * Flag indicating if the transport is shutting down. - */ - private volatile boolean isClosing = false; - - private KeepAliveScheduler keepAliveScheduler; - - /** - * Security validator for validating HTTP requests. - */ - private final ServerTransportSecurityValidator securityValidator; - - /** - * Constructs a new WebMvcStreamableServerTransportProvider instance. - * @param jsonMapper The McpJsonMapper to use for JSON serialization/deserialization - * of messages. - * @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC - * messages via HTTP. This endpoint will handle GET, POST, and DELETE requests. - * @param disallowDelete Whether to disallow DELETE requests on the endpoint. - * @param contextExtractor The context extractor for transport context from the - * request. - * @param keepAliveInterval The interval for keep-alive pings. If null, no keep-alive - * will be scheduled. - * @param securityValidator The security validator for validating HTTP requests. - * @throws IllegalArgumentException if any parameter is null - */ - private WebMvcStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, - boolean disallowDelete, McpTransportContextExtractor contextExtractor, - Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) { - Assert.notNull(jsonMapper, "McpJsonMapper must not be null"); - Assert.notNull(mcpEndpoint, "MCP endpoint must not be null"); - Assert.notNull(contextExtractor, "McpTransportContextExtractor must not be null"); - Assert.notNull(securityValidator, "Security validator must not be null"); - - this.jsonMapper = jsonMapper; - this.mcpEndpoint = mcpEndpoint; - this.disallowDelete = disallowDelete; - this.contextExtractor = contextExtractor; - 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 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; - } - - /** - * Broadcasts a notification to all connected clients through their SSE connections. - * If any errors occur during sending to a particular client, they are logged but - * don't prevent sending to other clients. - * @param method The method name for the notification - * @param params The parameters for the notification - * @return A Mono that completes when the broadcast attempt is finished - */ - @Override - public Mono notifyClients(String method, Object params) { - if (this.sessions.isEmpty()) { - logger.debug("No active sessions to broadcast message to"); - return Mono.empty(); - } - - logger.debug("Attempting to broadcast message to {} active sessions", this.sessions.size()); - - return Mono.fromRunnable(() -> { - this.sessions.values().parallelStream().forEach(session -> { - try { - session.sendNotification(method, params).block(); - } - catch (Exception e) { - logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()); - } - }); - }); - } - - /** - * Initiates a graceful shutdown of the transport. - * @return A Mono that completes when all cleanup operations are finished - */ - @Override - public Mono closeGracefully() { - return Mono.fromRunnable(() -> { - this.isClosing = true; - logger.debug("Initiating graceful shutdown with {} active sessions", this.sessions.size()); - - this.sessions.values().parallelStream().forEach(session -> { - try { - session.closeGracefully().block(); - } - catch (Exception e) { - logger.error("Failed to close session {}: {}", session.getId(), e.getMessage()); - } - }); - - this.sessions.clear(); - logger.debug("Graceful shutdown completed"); - }).then().doOnSuccess(v -> { - if (this.keepAliveScheduler != null) { - this.keepAliveScheduler.shutdown(); - } - }); - } - - /** - * Returns the RouterFunction that defines the HTTP endpoints for this transport. The - * router function handles three endpoints: - *

    - *
  • GET [mcpEndpoint] - For establishing SSE connections and message replay
  • - *
  • POST [mcpEndpoint] - For receiving JSON-RPC messages from clients
  • - *
  • DELETE [mcpEndpoint] - For session deletion (if enabled)
  • - *
- * @return The configured RouterFunction for handling HTTP requests - */ - public RouterFunction getRouterFunction() { - return this.routerFunction; - } - - /** - * Setup the listening SSE connections and message replay. - * @param request The incoming server request - * @return A ServerResponse configured for SSE communication, or an error response - */ - private ServerResponse handleGet(ServerRequest request) { - if (this.isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); - } - - try { - Map> headers = request.headers().asHttpHeaders(); - this.securityValidator.validateHeaders(headers); - } - catch (ServerTransportSecurityException e) { - return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); - } - - List acceptHeaders = request.headers().asHttpHeaders().getAccept(); - if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)) { - return ServerResponse.badRequest().body("Invalid Accept header. Expected TEXT_EVENT_STREAM"); - } - - McpTransportContext transportContext = this.contextExtractor.extract(request); - - if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) { - return ServerResponse.badRequest().body("Session ID required in mcp-session-id header"); - } - - String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); - McpStreamableServerSession session = this.sessions.get(sessionId); - - if (session == null) { - return ServerResponse.notFound().build(); - } - - logger.debug("Handling GET request for session: {}", sessionId); - - try { - return ServerResponse.sse(sseBuilder -> { - sseBuilder.onTimeout(() -> { - logger.debug("SSE connection timed out for session: {}", sessionId); - }); - - WebMvcStreamableMcpSessionTransport sessionTransport = new WebMvcStreamableMcpSessionTransport( - sessionId, sseBuilder); - - // Check if this is a replay request - if (!request.headers().header(HttpHeaders.LAST_EVENT_ID).isEmpty()) { - String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID); - - try { - session.replay(lastId) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .toIterable() - .forEach(message -> { - try { - sessionTransport.sendMessage(message) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - } - catch (Exception e) { - logger.error("Failed to replay message: {}", e.getMessage()); - sseBuilder.error(e); - } - }); - } - catch (Exception e) { - logger.error("Failed to replay messages: {}", e.getMessage()); - sseBuilder.error(e); - } - } - else { - // Establish new listening stream - McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session - .listeningStream(sessionTransport); - - sseBuilder.onComplete(() -> { - logger.debug("SSE connection completed for session: {}", sessionId); - listeningStream.close(); - }); - } - }, Duration.ZERO); - } - catch (Exception e) { - logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } - } - - /** - * Handles POST requests for incoming JSON-RPC messages from clients. - * @param request The incoming server request containing the JSON-RPC message - * @return A ServerResponse indicating success or appropriate error status - */ - private ServerResponse handlePost(ServerRequest request) { - if (this.isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); - } - - try { - Map> headers = request.headers().asHttpHeaders(); - this.securityValidator.validateHeaders(headers); - } - catch (ServerTransportSecurityException e) { - return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); - } - - List acceptHeaders = request.headers().asHttpHeaders().getAccept(); - if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM) - || !acceptHeaders.contains(MediaType.APPLICATION_JSON)) { - return ServerResponse.badRequest() - .body(new McpError("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON")); - } - - McpTransportContext transportContext = this.contextExtractor.extract(request); - - try { - String body = request.body(String.class); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, body); - - // Handle initialization request - if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest - && jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) { - McpSchema.InitializeRequest initializeRequest = jsonMapper.convertValue(jsonrpcRequest.params(), - new TypeRef() { - }); - McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory - .startSession(initializeRequest); - this.sessions.put(init.session().getId(), init.session()); - - try { - McpSchema.InitializeResult initResult = init.initResult().block(); - - return ServerResponse.ok() - .contentType(MediaType.APPLICATION_JSON) - .header(HttpHeaders.MCP_SESSION_ID, init.session().getId()) - .body(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, - null)); - } - catch (Exception e) { - logger.error("Failed to initialize session: {}", e.getMessage()); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(e.getMessage())); - } - } - - // Handle other messages that require a session - if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) { - return ServerResponse.badRequest().body(new McpError("Session ID missing")); - } - - String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); - McpStreamableServerSession session = this.sessions.get(sessionId); - - if (session == null) { - return ServerResponse.status(HttpStatus.NOT_FOUND) - .body(new McpError("Session not found: " + sessionId)); - } - - if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { - session.accept(jsonrpcResponse) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - return ServerResponse.accepted().build(); - } - else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { - session.accept(jsonrpcNotification) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - return ServerResponse.accepted().build(); - } - else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { - // For streaming responses, we need to return SSE - return ServerResponse.sse(sseBuilder -> { - sseBuilder.onComplete(() -> { - logger.debug("Request response stream completed for session: {}", sessionId); - }); - sseBuilder.onTimeout(() -> { - logger.debug("Request response stream timed out for session: {}", sessionId); - }); - - WebMvcStreamableMcpSessionTransport sessionTransport = new WebMvcStreamableMcpSessionTransport( - sessionId, sseBuilder); - - try { - session.responseStream(jsonrpcRequest, sessionTransport) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - } - catch (Exception e) { - logger.error("Failed to handle request stream: {}", e.getMessage()); - sseBuilder.error(e); - } - }, Duration.ZERO); - } - else { - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new McpError("Unknown message type")); - } - } - catch (IllegalArgumentException | IOException e) { - logger.error("Failed to deserialize message: {}", e.getMessage()); - return ServerResponse.badRequest().body(new McpError("Invalid message format")); - } - catch (Exception e) { - logger.error("Error handling message: {}", e.getMessage()); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(e.getMessage())); - } - } - - /** - * Handles DELETE requests for session deletion. - * @param request The incoming server request - * @return A ServerResponse indicating success or appropriate error status - */ - private ServerResponse handleDelete(ServerRequest request) { - if (this.isClosing) { - return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"); - } - - try { - Map> headers = request.headers().asHttpHeaders(); - this.securityValidator.validateHeaders(headers); - } - catch (ServerTransportSecurityException e) { - return ServerResponse.status(e.getStatusCode()).body(e.getMessage()); - } - - if (this.disallowDelete) { - return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build(); - } - - McpTransportContext transportContext = this.contextExtractor.extract(request); - - if (request.headers().header(HttpHeaders.MCP_SESSION_ID).isEmpty()) { - return ServerResponse.badRequest().body("Session ID required in mcp-session-id header"); - } - - String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); - McpStreamableServerSession session = this.sessions.get(sessionId); - - if (session == null) { - return ServerResponse.notFound().build(); - } - - try { - session.delete().contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); - this.sessions.remove(sessionId); - return ServerResponse.ok().build(); - } - catch (Exception e) { - logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); - return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(e.getMessage())); - } - } - - /** - * Implementation of McpStreamableServerTransport for WebMVC SSE sessions. This class - * handles the transport-level communication for a specific client session. - * - *

- * This class is thread-safe and uses a ReentrantLock to synchronize access to the - * underlying SSE builder to prevent race conditions when multiple threads attempt to - * send messages concurrently. - */ - private class WebMvcStreamableMcpSessionTransport implements McpStreamableServerTransport { - - private final String sessionId; - - private final SseBuilder sseBuilder; - - private final ReentrantLock lock = new ReentrantLock(); - - private volatile boolean closed = false; - - /** - * Creates a new session transport with the specified ID and SSE builder. - * @param sessionId The unique identifier for this session - * @param sseBuilder The SSE builder for sending server events to the client - */ - WebMvcStreamableMcpSessionTransport(String sessionId, SseBuilder sseBuilder) { - this.sessionId = sessionId; - this.sseBuilder = sseBuilder; - logger.debug("Streamable session transport {} initialized with SSE builder", sessionId); - } - - /** - * Sends a JSON-RPC message to the client through the SSE connection. - * @param message The JSON-RPC message to send - * @return A Mono that completes when the message has been sent - */ - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - return sendMessage(message, null); - } - - /** - * Sends a JSON-RPC message to the client through the SSE connection with a - * specific message ID. - * @param message The JSON-RPC message to send - * @param messageId The message ID for SSE event identification - * @return A Mono that completes when the message has been sent - */ - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { - return Mono.fromRunnable(() -> { - if (this.closed) { - logger.debug("Attempted to send message to closed session: {}", this.sessionId); - return; - } - - this.lock.lock(); - try { - if (this.closed) { - logger.debug("Session {} was closed during message send attempt", this.sessionId); - return; - } - - String jsonText = jsonMapper.writeValueAsString(message); - this.sseBuilder.id(messageId != null ? messageId : this.sessionId) - .event(MESSAGE_EVENT_TYPE) - .data(jsonText); - logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId); - } - catch (Exception e) { - logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); - try { - this.sseBuilder.error(e); - } - catch (Exception errorException) { - logger.error("Failed to send error to SSE builder for session {}: {}", this.sessionId, - errorException.getMessage()); - } - } - finally { - this.lock.unlock(); - } - }); - } - - /** - * Converts data from one type to another using the configured McpJsonMapper. - * @param data The source data object to convert - * @param typeRef The target type reference - * @return The converted object of type T - * @param The target type - */ - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return jsonMapper.convertValue(data, typeRef); - } - - /** - * Initiates a graceful shutdown of the transport. - * @return A Mono that completes when the shutdown is complete - */ - @Override - public Mono closeGracefully() { - return Mono.fromRunnable(() -> { - WebMvcStreamableMcpSessionTransport.this.close(); - }); - } - - /** - * Closes the transport immediately. - */ - @Override - public void close() { - this.lock.lock(); - try { - if (this.closed) { - logger.debug("Session transport {} already closed", this.sessionId); - return; - } - - this.closed = true; - - this.sseBuilder.complete(); - logger.debug("Successfully completed SSE builder for session {}", sessionId); - } - catch (Exception e) { - logger.warn("Failed to complete SSE builder for session {}: {}", sessionId, e.getMessage()); - } - finally { - this.lock.unlock(); - } - } - - } - - public static Builder builder() { - return new Builder(); - } - - /** - * Builder for creating instances of {@link WebMvcStreamableServerTransportProvider}. - */ - public static class Builder { - - private McpJsonMapper jsonMapper; - - private String mcpEndpoint = "/mcp"; - - private boolean disallowDelete = false; - - private McpTransportContextExtractor contextExtractor = ( - serverRequest) -> McpTransportContext.EMPTY; - - private Duration keepAliveInterval; - - 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, "McpJsonMapper must not be null"); - this.jsonMapper = jsonMapper; - return this; - } - - /** - * Sets the endpoint URI where clients should send their JSON-RPC messages. - * @param mcpEndpoint The MCP endpoint URI. Must not be null. - * @return this builder instance - * @throws IllegalArgumentException if mcpEndpoint is null - */ - public Builder mcpEndpoint(String mcpEndpoint) { - Assert.notNull(mcpEndpoint, "MCP endpoint must not be null"); - this.mcpEndpoint = mcpEndpoint; - return this; - } - - /** - * Sets whether to disallow DELETE requests on the endpoint. - * @param disallowDelete true to disallow DELETE requests, false otherwise - * @return this builder instance - */ - public Builder disallowDelete(boolean disallowDelete) { - this.disallowDelete = disallowDelete; - 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 contextExtractor) { - Assert.notNull(contextExtractor, "contextExtractor must not be null"); - this.contextExtractor = contextExtractor; - return this; - } - - /** - * Sets the keep-alive interval for the transport. If set, a keep-alive scheduler - * will be created to periodically check and send keep-alive messages to clients. - * @param keepAliveInterval The interval duration for keep-alive messages, or null - * to disable keep-alive - * @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 WebMvcStreamableServerTransportProvider} with - * the configured settings. - * @return A new WebMvcStreamableServerTransportProvider instance - * @throws IllegalStateException if required parameters are not set - */ - public WebMvcStreamableServerTransportProvider build() { - Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); - return new WebMvcStreamableServerTransportProvider( - jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval, securityValidator); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/common/McpTransportContextIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/common/McpTransportContextIntegrationTests.java deleted file mode 100644 index cc9945436..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/common/McpTransportContextIntegrationTests.java +++ /dev/null @@ -1,297 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - */ - -package io.modelcontextprotocol.common; - -import java.util.Map; -import java.util.function.BiFunction; -import java.util.function.Supplier; - -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.client.McpSyncClient; -import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; -import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; -import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpStatelessServerFeatures; -import io.modelcontextprotocol.server.McpStatelessSyncServer; -import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.server.McpTransportContextExtractor; -import io.modelcontextprotocol.server.TestUtil; -import io.modelcontextprotocol.server.TomcatTestUtil; -import io.modelcontextprotocol.server.TomcatTestUtil.TomcatServer; -import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; -import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport; -import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpSchema; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.LifecycleState; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerRequest; -import org.springframework.web.servlet.function.ServerResponse; -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Integration tests for {@link McpTransportContext} propagation between MCP clients and - * servers using Spring WebMVC transport implementations. - * - *

- * This test class validates the end-to-end flow of transport context propagation across - * different MCP transport mechanisms in a Spring WebMVC environment. It demonstrates how - * contextual information can be passed from client to server through HTTP headers and - * properly extracted and utilized on the server side. - * - *

Transport Types Tested

- *
    - *
  • Stateless: Tests context propagation with - * {@link WebMvcStatelessServerTransport} where each request is independent
  • - *
  • Streamable HTTP: Tests context propagation with - * {@link WebMvcStreamableServerTransportProvider} supporting stateful server - * sessions
  • - *
  • Server-Sent Events (SSE): Tests context propagation with - * {@link WebMvcSseServerTransportProvider} for long-lived connections
  • - *
- * - * @author Daniel Garnier-Moiroux - * @author Christian Tzolov - */ -@Timeout(15) -public class McpTransportContextIntegrationTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private TomcatServer tomcatServer; - - private static final ThreadLocal CLIENT_SIDE_HEADER_VALUE_HOLDER = new ThreadLocal<>(); - - private static final String HEADER_NAME = "x-test"; - - private final Supplier clientContextProvider = () -> { - var headerValue = CLIENT_SIDE_HEADER_VALUE_HOLDER.get(); - return headerValue != null ? McpTransportContext.create(Map.of("client-side-header-value", headerValue)) - : McpTransportContext.EMPTY; - }; - - private final McpSyncHttpClientRequestCustomizer clientRequestCustomizer = (builder, method, endpoint, body, - context) -> { - var headerValue = context.get("client-side-header-value"); - if (headerValue != null) { - builder.header(HEADER_NAME, headerValue.toString()); - } - }; - - private static final BiFunction statelessHandler = ( - transportContext, - request) -> new McpSchema.CallToolResult(transportContext.get("server-side-header-value").toString(), null); - - private static final BiFunction statefulHandler = ( - exchange, request) -> statelessHandler.apply(exchange.transportContext(), request); - - private static McpTransportContextExtractor serverContextExtractor = (ServerRequest r) -> { - String headerValue = r.servletRequest().getHeader(HEADER_NAME); - return headerValue != null ? McpTransportContext.create(Map.of("server-side-header-value", headerValue)) - : McpTransportContext.EMPTY; - }; - - private final McpSyncClient streamableClient = McpClient - .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) - .httpRequestCustomizer(clientRequestCustomizer) - .build()) - .transportContextProvider(clientContextProvider) - .build(); - - private final McpSyncClient sseClient = McpClient - .sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT) - .httpRequestCustomizer(clientRequestCustomizer) - .build()) - .transportContextProvider(clientContextProvider) - .build(); - - private static final McpSchema.Tool tool = McpSchema.Tool.builder() - .name("test-tool") - .description("return the value of the x-test header from call tool request") - .build(); - - @AfterEach - public void after() { - CLIENT_SIDE_HEADER_VALUE_HOLDER.remove(); - if (streamableClient != null) { - streamableClient.closeGracefully(); - } - if (sseClient != null) { - sseClient.closeGracefully(); - } - stopTomcat(); - } - - @Test - void statelessServer() { - startTomcat(TestStatelessConfig.class); - - McpSchema.InitializeResult initResult = streamableClient.initialize(); - assertThat(initResult).isNotNull(); - - CLIENT_SIDE_HEADER_VALUE_HOLDER.set("some important value"); - McpSchema.CallToolResult response = streamableClient - .callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo("some important value"); - } - - @Test - void streamableServer() { - - startTomcat(TestStreamableHttpConfig.class); - - McpSchema.InitializeResult initResult = streamableClient.initialize(); - assertThat(initResult).isNotNull(); - - CLIENT_SIDE_HEADER_VALUE_HOLDER.set("some important value"); - McpSchema.CallToolResult response = streamableClient - .callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo("some important value"); - } - - @Test - void sseServer() { - startTomcat(TestSseConfig.class); - - McpSchema.InitializeResult initResult = sseClient.initialize(); - assertThat(initResult).isNotNull(); - - CLIENT_SIDE_HEADER_VALUE_HOLDER.set("some important value"); - McpSchema.CallToolResult response = sseClient.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo("some important value"); - } - - private void startTomcat(Class componentClass) { - tomcatServer = TomcatTestUtil.createTomcatServer("", PORT, componentClass); - try { - tomcatServer.tomcat().start(); - assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED); - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - } - - private void stopTomcat() { - if (tomcatServer != null && tomcatServer.tomcat() != null) { - try { - tomcatServer.tomcat().stop(); - tomcatServer.tomcat().destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } - } - - @Configuration - @EnableWebMvc - static class TestStatelessConfig { - - @Bean - public WebMvcStatelessServerTransport webMvcStatelessServerTransport() { - - return WebMvcStatelessServerTransport.builder().contextExtractor(serverContextExtractor).build(); - } - - @Bean - public RouterFunction routerFunction(WebMvcStatelessServerTransport transportProvider) { - return transportProvider.getRouterFunction(); - } - - @Bean - public McpStatelessSyncServer mcpStatelessServer(WebMvcStatelessServerTransport transportProvider) { - return McpServer.sync(transportProvider) - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpStatelessServerFeatures.SyncToolSpecification(tool, statelessHandler)) - .build(); - } - - } - - @Configuration - @EnableWebMvc - static class TestStreamableHttpConfig { - - @Bean - public WebMvcStreamableServerTransportProvider webMvcStreamableServerTransport() { - - return WebMvcStreamableServerTransportProvider.builder().contextExtractor(serverContextExtractor).build(); - } - - @Bean - public RouterFunction routerFunction( - WebMvcStreamableServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - @Bean - public McpSyncServer mcpStreamableServer(WebMvcStreamableServerTransportProvider transportProvider) { - return McpServer.sync(transportProvider) - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.SyncToolSpecification(tool, null, statefulHandler)) - .build(); - } - - } - - @Configuration - @EnableWebMvc - static class TestSseConfig { - - @Bean - public WebMvcSseServerTransportProvider webMvcSseServerTransport() { - - return WebMvcSseServerTransportProvider.builder() - .contextExtractor(serverContextExtractor) - .messageEndpoint("/mcp/message") - .build(); - } - - @Bean - public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - @Bean - public McpSyncServer mcpSseServer(WebMvcSseServerTransportProvider transportProvider) { - return McpServer.sync(transportProvider) - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.SyncToolSpecification(tool, null, statefulHandler)) - .build(); - - } - - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java deleted file mode 100644 index 4b8ea73be..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/security/ServerTransportSecurityIntegrationTests.java +++ /dev/null @@ -1,378 +0,0 @@ -/* - * Copyright 2026-2026 the original author or authors. - */ - -package io.modelcontextprotocol.security; - -import java.net.URI; -import java.net.http.HttpRequest; -import java.time.Duration; -import java.util.stream.Stream; - -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.client.McpSyncClient; -import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; -import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; -import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; -import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.json.McpJsonDefaults; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpStatelessSyncServer; -import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.server.TestUtil; -import io.modelcontextprotocol.server.TomcatTestUtil; -import io.modelcontextprotocol.server.TomcatTestUtil.TomcatServer; -import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; -import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; -import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport; -import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpSchema; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.LifecycleState; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.BeforeParameterizedClassInvocation; -import org.junit.jupiter.params.Parameter; -import org.junit.jupiter.params.ParameterizedClass; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; -import org.springframework.context.annotation.Scope; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerResponse; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Named.named; -import static org.junit.jupiter.params.provider.Arguments.arguments; - -/** - * Test the header security validation for all transport types. - * - * @author Daniel Garnier-Moiroux - */ -@ParameterizedClass -@MethodSource("transports") -public class ServerTransportSecurityIntegrationTests { - - private static final String DISALLOWED_ORIGIN = "https://malicious.example.com"; - - private static final String DISALLOWED_HOST = "malicious.example.com:8080"; - - @Parameter - private static Class configClass; - - private static TomcatServer tomcatServer; - - private static String baseUrl; - - @BeforeParameterizedClassInvocation - static void createTransportAndStartTomcat(Class configClass) { - var port = TestUtil.findAvailablePort(); - baseUrl = "http://localhost:" + port; - startTomcat(configClass, port); - } - - @AfterAll - static void afterAll() { - stopTomcat(); - } - - private McpSyncClient mcpClient; - - private TestRequestCustomizer requestCustomizer; - - @BeforeEach - void setUp() { - mcpClient = tomcatServer.appContext().getBean(McpSyncClient.class); - requestCustomizer = tomcatServer.appContext().getBean(TestRequestCustomizer.class); - requestCustomizer.reset(); - } - - @AfterEach - void tearDown() { - mcpClient.close(); - } - - @Test - void originAllowed() { - requestCustomizer.setOriginHeader(baseUrl); - var result = mcpClient.initialize(); - var tools = mcpClient.listTools(); - - assertThat(result.protocolVersion()).isNotEmpty(); - assertThat(tools.tools()).isEmpty(); - } - - @Test - void noOrigin() { - requestCustomizer.setOriginHeader(null); - var result = mcpClient.initialize(); - var tools = mcpClient.listTools(); - - assertThat(result.protocolVersion()).isNotEmpty(); - assertThat(tools.tools()).isEmpty(); - } - - @Test - void connectOriginNotAllowed() { - requestCustomizer.setOriginHeader(DISALLOWED_ORIGIN); - assertThatThrownBy(() -> mcpClient.initialize()); - } - - @Test - void messageOriginNotAllowed() { - requestCustomizer.setOriginHeader(baseUrl); - mcpClient.initialize(); - requestCustomizer.setOriginHeader(DISALLOWED_ORIGIN); - assertThatThrownBy(() -> mcpClient.listTools()); - } - - @Test - void hostAllowed() { - // Host header is set by default by HttpClient to the request URI host - var result = mcpClient.initialize(); - var tools = mcpClient.listTools(); - - assertThat(result.protocolVersion()).isNotEmpty(); - assertThat(tools.tools()).isEmpty(); - } - - @Test - void connectHostNotAllowed() { - requestCustomizer.setHostHeader(DISALLOWED_HOST); - assertThatThrownBy(() -> mcpClient.initialize()); - } - - @Test - void messageHostNotAllowed() { - mcpClient.initialize(); - requestCustomizer.setHostHeader(DISALLOWED_HOST); - assertThatThrownBy(() -> mcpClient.listTools()); - } - - // ---------------------------------------------------- - // Tomcat management - // ---------------------------------------------------- - - private static void startTomcat(Class componentClass, int port) { - tomcatServer = TomcatTestUtil.createTomcatServer("", port, componentClass); - try { - tomcatServer.tomcat().start(); - assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED); - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - } - - private static void stopTomcat() { - if (tomcatServer != null) { - if (tomcatServer.appContext() != null) { - tomcatServer.appContext().close(); - } - if (tomcatServer.tomcat() != null) { - try { - tomcatServer.tomcat().stop(); - tomcatServer.tomcat().destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } - } - } - - // ---------------------------------------------------- - // Transport servers to test - // ---------------------------------------------------- - - /** - * All transport types we want to test. We use a {@link MethodSource} rather than a - * {@link org.junit.jupiter.params.provider.ValueSource} to provide a readable name. - */ - static Stream transports() { - //@formatter:off - return Stream.of( - arguments(named("SSE", SseConfig.class)), - arguments(named("Streamable HTTP", StreamableHttpConfig.class)), - arguments(named("Stateless", StatelessConfig.class)) - ); - //@formatter:on - } - - // ---------------------------------------------------- - // Spring Configuration classes - // ---------------------------------------------------- - - @Configuration - static class CommonConfig { - - @Bean - TestRequestCustomizer requestCustomizer() { - return new TestRequestCustomizer(); - } - - @Bean - DefaultServerTransportSecurityValidator validator() { - return DefaultServerTransportSecurityValidator.builder() - .allowedOrigin("http://localhost:*") - .allowedHost("localhost:*") - .build(); - } - - } - - @Configuration - @EnableWebMvc - @Import(CommonConfig.class) - static class SseConfig { - - @Bean - @Scope("prototype") - McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { - var transport = HttpClientSseClientTransport.builder(baseUrl) - .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonDefaults.getMapper()) - .build(); - return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); - } - - @Bean - public WebMvcSseServerTransportProvider webMvcSseServerTransport( - DefaultServerTransportSecurityValidator validator) { - return WebMvcSseServerTransportProvider.builder() - .messageEndpoint("/mcp/message") - .securityValidator(validator) - .build(); - } - - @Bean - public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - @Bean - public McpSyncServer mcpServer(WebMvcSseServerTransportProvider transportProvider) { - return McpServer.sync(transportProvider) - .serverInfo("test-server", "1.0.0") - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .build(); - } - - } - - @Configuration - @EnableWebMvc - @Import(CommonConfig.class) - static class StreamableHttpConfig { - - @Bean - @Scope("prototype") - McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { - var transport = HttpClientStreamableHttpTransport.builder(baseUrl) - .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonDefaults.getMapper()) - .openConnectionOnStartup(true) - .build(); - return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); - } - - @Bean - public WebMvcStreamableServerTransportProvider webMvcStreamableServerTransport( - DefaultServerTransportSecurityValidator validator) { - return WebMvcStreamableServerTransportProvider.builder().securityValidator(validator).build(); - } - - @Bean - public RouterFunction routerFunction( - WebMvcStreamableServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - @Bean - public McpSyncServer mcpServer(WebMvcStreamableServerTransportProvider transportProvider) { - return McpServer.sync(transportProvider) - .serverInfo("test-server", "1.0.0") - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .build(); - } - - } - - @Configuration - @EnableWebMvc - @Import(CommonConfig.class) - static class StatelessConfig { - - @Bean - @Scope("prototype") - McpSyncClient createMcpClient(McpSyncHttpClientRequestCustomizer requestCustomizer) { - var transport = HttpClientStreamableHttpTransport.builder(baseUrl) - .httpRequestCustomizer(requestCustomizer) - .jsonMapper(McpJsonDefaults.getMapper()) - .openConnectionOnStartup(true) - .build(); - return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); - } - - @Bean - public WebMvcStatelessServerTransport webMvcStatelessServerTransport( - DefaultServerTransportSecurityValidator validator) { - return WebMvcStatelessServerTransport.builder().securityValidator(validator).build(); - } - - @Bean - public RouterFunction routerFunction(WebMvcStatelessServerTransport transportProvider) { - return transportProvider.getRouterFunction(); - } - - @Bean - public McpStatelessSyncServer mcpStatelessServer(WebMvcStatelessServerTransport transportProvider) { - return McpServer.sync(transportProvider) - .serverInfo("test-server", "1.0.0") - .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .build(); - } - - } - - static class TestRequestCustomizer implements McpSyncHttpClientRequestCustomizer { - - private String originHeader = null; - - private String hostHeader = null; - - @Override - public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body, - McpTransportContext context) { - if (originHeader != null) { - builder.header("Origin", originHeader); - } - if (hostHeader != null) { - builder.header("Host", hostHeader); - } - } - - public void setOriginHeader(String originHeader) { - this.originHeader = originHeader; - } - - public void setHostHeader(String hostHeader) { - this.hostHeader = hostHeader; - } - - public void reset() { - this.originHeader = null; - this.hostHeader = null; - } - - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/TomcatTestUtil.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/TomcatTestUtil.java deleted file mode 100644 index 8625b6a70..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/TomcatTestUtil.java +++ /dev/null @@ -1,64 +0,0 @@ -/* -* Copyright 2025 - 2025 the original author or authors. -*/ -package io.modelcontextprotocol.server; - -import org.apache.catalina.Context; -import org.apache.catalina.startup.Tomcat; - -import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; -import org.springframework.web.servlet.DispatcherServlet; - -/** - * @author Christian Tzolov - */ -public class TomcatTestUtil { - - TomcatTestUtil() { - // Prevent instantiation - } - - public record TomcatServer(Tomcat tomcat, AnnotationConfigWebApplicationContext appContext) { - } - - public static TomcatServer createTomcatServer(String contextPath, int port, Class componentClass) { - - // Set up Tomcat first - var tomcat = new Tomcat(); - tomcat.setPort(port); - - // Set Tomcat base directory to java.io.tmpdir to avoid permission issues - String baseDir = System.getProperty("java.io.tmpdir"); - tomcat.setBaseDir(baseDir); - - // Use the same directory for document base - Context context = tomcat.addContext(contextPath, baseDir); - - // Create and configure Spring WebMvc context - var appContext = new AnnotationConfigWebApplicationContext(); - appContext.register(componentClass); - appContext.setServletContext(context.getServletContext()); - appContext.refresh(); - - // Create DispatcherServlet with our Spring context - DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext); - - // Add servlet to Tomcat and get the wrapper - var wrapper = Tomcat.addServlet(context, "dispatcherServlet", dispatcherServlet); - wrapper.setLoadOnStartup(1); - wrapper.setAsyncSupported(true); - context.addServletMappingDecoded("/*", "dispatcherServlet"); - - try { - // Configure and start the connector with async support - var connector = tomcat.getConnector(); - connector.setAsyncTimeout(3000); // 3 seconds timeout for async requests - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - return new TomcatServer(tomcat, appContext); - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMcpStreamableAsyncServerTransportTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMcpStreamableAsyncServerTransportTests.java deleted file mode 100644 index 36aaa27fb..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMcpStreamableAsyncServerTransportTests.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import org.apache.catalina.Context; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.startup.Tomcat; -import org.junit.jupiter.api.Timeout; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; -import org.springframework.web.servlet.DispatcherServlet; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerResponse; - -import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; -import reactor.netty.DisposableServer; - -/** - * Tests for {@link McpAsyncServer} using {@link WebMvcSseServerTransportProvider}. - * - * @author Christian Tzolov - */ -@Timeout(15) // Giving extra time beyond the client timeout -class WebMcpStreamableAsyncServerTransportTests extends AbstractMcpAsyncServerTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MCP_ENDPOINT = "/mcp"; - - private DisposableServer httpServer; - - private AnnotationConfigWebApplicationContext appContext; - - private Tomcat tomcat; - - private McpStreamableServerTransportProvider transportProvider; - - @Configuration - @EnableWebMvc - static class TestConfig { - - @Bean - public WebMvcStreamableServerTransportProvider webMvcSseServerTransportProvider() { - return WebMvcStreamableServerTransportProvider.builder().mcpEndpoint(MCP_ENDPOINT).build(); - } - - @Bean - public RouterFunction routerFunction( - WebMvcStreamableServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - } - - private McpStreamableServerTransportProvider createMcpTransportProvider() { - // Set up Tomcat first - tomcat = new Tomcat(); - tomcat.setPort(PORT); - - // Set Tomcat base directory to java.io.tmpdir to avoid permission issues - String baseDir = System.getProperty("java.io.tmpdir"); - tomcat.setBaseDir(baseDir); - - // Use the same directory for document base - Context context = tomcat.addContext("", baseDir); - - // Create and configure Spring WebMvc context - appContext = new AnnotationConfigWebApplicationContext(); - appContext.register(TestConfig.class); - appContext.setServletContext(context.getServletContext()); - appContext.refresh(); - - // Get the transport from Spring context - transportProvider = appContext.getBean(McpStreamableServerTransportProvider.class); - - // Create DispatcherServlet with our Spring context - DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext); - - // Add servlet to Tomcat and get the wrapper - var wrapper = Tomcat.addServlet(context, "dispatcherServlet", dispatcherServlet); - wrapper.setLoadOnStartup(1); - context.addServletMappingDecoded("/*", "dispatcherServlet"); - - try { - tomcat.start(); - tomcat.getConnector(); // Create and start the connector - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - return transportProvider; - } - - @Override - protected McpServer.AsyncSpecification prepareAsyncServerBuilder() { - return McpServer.async(createMcpTransportProvider()); - } - - @Override - protected void onStart() { - } - - @Override - protected void onClose() { - if (httpServer != null) { - httpServer.disposeNow(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMcpStreamableSyncServerTransportTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMcpStreamableSyncServerTransportTests.java deleted file mode 100644 index 2f75551eb..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMcpStreamableSyncServerTransportTests.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import org.apache.catalina.Context; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.startup.Tomcat; -import org.junit.jupiter.api.Timeout; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; -import org.springframework.web.servlet.DispatcherServlet; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerResponse; - -import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; -import reactor.netty.DisposableServer; - -/** - * Tests for {@link McpAsyncServer} using {@link WebMvcSseServerTransportProvider}. - * - * @author Christian Tzolov - */ -@Timeout(15) // Giving extra time beyond the client timeout -class WebMcpStreamableSyncServerTransportTests extends AbstractMcpSyncServerTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MCP_ENDPOINT = "/mcp"; - - private DisposableServer httpServer; - - private AnnotationConfigWebApplicationContext appContext; - - private Tomcat tomcat; - - private McpStreamableServerTransportProvider transportProvider; - - @Configuration - @EnableWebMvc - static class TestConfig { - - @Bean - public WebMvcStreamableServerTransportProvider webMvcSseServerTransportProvider() { - return WebMvcStreamableServerTransportProvider.builder().mcpEndpoint(MCP_ENDPOINT).build(); - } - - @Bean - public RouterFunction routerFunction( - WebMvcStreamableServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - } - - private McpStreamableServerTransportProvider createMcpTransportProvider() { - // Set up Tomcat first - tomcat = new Tomcat(); - tomcat.setPort(PORT); - - // Set Tomcat base directory to java.io.tmpdir to avoid permission issues - String baseDir = System.getProperty("java.io.tmpdir"); - tomcat.setBaseDir(baseDir); - - // Use the same directory for document base - Context context = tomcat.addContext("", baseDir); - - // Create and configure Spring WebMvc context - appContext = new AnnotationConfigWebApplicationContext(); - appContext.register(TestConfig.class); - appContext.setServletContext(context.getServletContext()); - appContext.refresh(); - - // Get the transport from Spring context - transportProvider = appContext.getBean(McpStreamableServerTransportProvider.class); - - // Create DispatcherServlet with our Spring context - DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext); - - // Add servlet to Tomcat and get the wrapper - var wrapper = Tomcat.addServlet(context, "dispatcherServlet", dispatcherServlet); - wrapper.setLoadOnStartup(1); - context.addServletMappingDecoded("/*", "dispatcherServlet"); - - try { - tomcat.start(); - tomcat.getConnector(); // Create and start the connector - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - return transportProvider; - } - - @Override - protected McpServer.SyncSpecification prepareSyncServerBuilder() { - return McpServer.sync(createMcpTransportProvider()); - } - - @Override - protected void onStart() { - } - - @Override - protected void onClose() { - if (httpServer != null) { - httpServer.disposeNow(); - } - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportTests.java deleted file mode 100644 index ccf3170c9..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseAsyncServerTransportTests.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; -import io.modelcontextprotocol.spec.McpServerTransportProvider; -import org.apache.catalina.Context; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.startup.Tomcat; -import org.junit.jupiter.api.Timeout; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; -import org.springframework.web.servlet.DispatcherServlet; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerResponse; - -@Timeout(15) -class WebMvcSseAsyncServerTransportTests extends AbstractMcpAsyncServerTests { - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private static final int PORT = TestUtil.findAvailablePort(); - - private Tomcat tomcat; - - private McpServerTransportProvider transportProvider; - - @Configuration - @EnableWebMvc - static class TestConfig { - - @Bean - public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() { - return WebMvcSseServerTransportProvider.builder() - .messageEndpoint(MESSAGE_ENDPOINT) - .sseEndpoint(WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT) - .build(); - } - - @Bean - public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - } - - private AnnotationConfigWebApplicationContext appContext; - - private McpServerTransportProvider createMcpTransportProvider() { - // Set up Tomcat first - tomcat = new Tomcat(); - tomcat.setPort(PORT); - - // Set Tomcat base directory to java.io.tmpdir to avoid permission issues - String baseDir = System.getProperty("java.io.tmpdir"); - tomcat.setBaseDir(baseDir); - - // Use the same directory for document base - Context context = tomcat.addContext("", baseDir); - - // Create and configure Spring WebMvc context - appContext = new AnnotationConfigWebApplicationContext(); - appContext.register(TestConfig.class); - appContext.setServletContext(context.getServletContext()); - appContext.refresh(); - - // Get the transport from Spring context - transportProvider = appContext.getBean(WebMvcSseServerTransportProvider.class); - - // Create DispatcherServlet with our Spring context - DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext); - - // Add servlet to Tomcat and get the wrapper - var wrapper = Tomcat.addServlet(context, "dispatcherServlet", dispatcherServlet); - wrapper.setLoadOnStartup(1); - context.addServletMappingDecoded("/*", "dispatcherServlet"); - - try { - tomcat.start(); - tomcat.getConnector(); // Create and start the connector - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - return transportProvider; - } - - @Override - protected McpServer.AsyncSpecification prepareAsyncServerBuilder() { - return McpServer.async(createMcpTransportProvider()); - } - - @Override - protected void onStart() { - } - - @Override - protected void onClose() { - if (transportProvider != null) { - transportProvider.closeGracefully().block(); - } - if (appContext != null) { - appContext.close(); - } - if (tomcat != null) { - try { - tomcat.stop(); - tomcat.destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseCustomContextPathTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseCustomContextPathTests.java deleted file mode 100644 index d8d26af48..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseCustomContextPathTests.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2024 - 2024 the original author or authors. - */ -package io.modelcontextprotocol.server; - -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; -import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; -import io.modelcontextprotocol.spec.McpSchema; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.LifecycleState; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerResponse; - -import static org.assertj.core.api.Assertions.assertThat; - -class WebMvcSseCustomContextPathTests { - - private static final String CUSTOM_CONTEXT_PATH = "/app/1"; - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private WebMvcSseServerTransportProvider mcpServerTransportProvider; - - McpClient.SyncSpec clientBuilder; - - private TomcatTestUtil.TomcatServer tomcatServer; - - @BeforeEach - public void before() { - - tomcatServer = TomcatTestUtil.createTomcatServer(CUSTOM_CONTEXT_PATH, PORT, TestConfig.class); - - try { - tomcatServer.tomcat().start(); - assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED); - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - var clientTransport = HttpClientSseClientTransport.builder("http://localhost:" + PORT) - .sseEndpoint(CUSTOM_CONTEXT_PATH + WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT) - .build(); - - clientBuilder = McpClient.sync(clientTransport); - - mcpServerTransportProvider = tomcatServer.appContext().getBean(WebMvcSseServerTransportProvider.class); - } - - @AfterEach - public void after() { - if (mcpServerTransportProvider != null) { - mcpServerTransportProvider.closeGracefully().block(); - } - if (tomcatServer.appContext() != null) { - tomcatServer.appContext().close(); - } - if (tomcatServer.tomcat() != null) { - try { - tomcatServer.tomcat().stop(); - tomcatServer.tomcat().destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } - } - - @Test - void testCustomContextPath() { - McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").build(); - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build(); - assertThat(client.initialize()).isNotNull(); - } - - @Configuration - @EnableWebMvc - static class TestConfig { - - @Bean - public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() { - - return WebMvcSseServerTransportProvider.builder() - .baseUrl(CUSTOM_CONTEXT_PATH) - .messageEndpoint(MESSAGE_ENDPOINT) - .sseEndpoint(WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT) - .build(); - // return new WebMvcSseServerTransportProvider(new ObjectMapper(), - // CUSTOM_CONTEXT_PATH, MESSAGE_ENDPOINT, - // WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT); - } - - @Bean - public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java deleted file mode 100644 index 045f9b3dd..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2024 - 2024 the original author or authors. - */ -package io.modelcontextprotocol.server; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Duration; -import java.util.Map; -import java.util.stream.Stream; - -import org.apache.catalina.LifecycleException; -import org.apache.catalina.LifecycleState; -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.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerRequest; -import org.springframework.web.servlet.function.ServerResponse; - -import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests; -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.AsyncSpecification; -import io.modelcontextprotocol.server.McpServer.SingleSessionSyncSpecification; -import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; -import reactor.core.scheduler.Schedulers; - -@Timeout(15) -class WebMvcSseIntegrationTests extends AbstractMcpClientServerIntegrationTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private WebMvcSseServerTransportProvider mcpServerTransportProvider; - - static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = r -> McpTransportContext - .create(Map.of("important", "value")); - - static Stream 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).build()) - .requestTimeout(Duration.ofHours(10))); - - clientBuilders.put("webflux", McpClient - .sync(WebFluxSseClientTransport.builder(WebClient.builder().baseUrl("http://localhost:" + port)).build()) - .requestTimeout(Duration.ofHours(10))); - } - - @Configuration - @EnableWebMvc - static class TestConfig { - - @Bean - public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() { - return WebMvcSseServerTransportProvider.builder() - .messageEndpoint(MESSAGE_ENDPOINT) - .contextExtractor(TEST_CONTEXT_EXTRACTOR) - .build(); - } - - @Bean - public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - } - - private TomcatTestUtil.TomcatServer tomcatServer; - - @BeforeEach - public void before() { - - tomcatServer = TomcatTestUtil.createTomcatServer("", PORT, TestConfig.class); - - try { - tomcatServer.tomcat().start(); - assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED); - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - prepareClients(PORT, MESSAGE_ENDPOINT); - - // Get the transport from Spring context - mcpServerTransportProvider = tomcatServer.appContext().getBean(WebMvcSseServerTransportProvider.class); - - } - - @AfterEach - public void after() { - reactor.netty.http.HttpResources.disposeLoopsAndConnections(); - if (mcpServerTransportProvider != null) { - mcpServerTransportProvider.closeGracefully().block(); - } - Schedulers.shutdownNow(); - if (tomcatServer.appContext() != null) { - tomcatServer.appContext().close(); - } - if (tomcatServer.tomcat() != null) { - try { - tomcatServer.tomcat().stop(); - tomcatServer.tomcat().destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } - } - - @Override - protected AsyncSpecification prepareAsyncServerBuilder() { - return McpServer.async(mcpServerTransportProvider); - } - - @Override - protected SingleSessionSyncSpecification prepareSyncServerBuilder() { - return McpServer.sync(mcpServerTransportProvider); - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportTests.java deleted file mode 100644 index 66d6d3ae9..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseSyncServerTransportTests.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider; -import org.apache.catalina.Context; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.startup.Tomcat; -import org.junit.jupiter.api.Timeout; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; -import org.springframework.web.servlet.DispatcherServlet; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerResponse; - -@Timeout(15) -class WebMvcSseSyncServerTransportTests extends AbstractMcpSyncServerTests { - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private static final int PORT = TestUtil.findAvailablePort(); - - private Tomcat tomcat; - - private WebMvcSseServerTransportProvider transportProvider; - - @Configuration - @EnableWebMvc - static class TestConfig { - - @Bean - public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() { - return WebMvcSseServerTransportProvider.builder().messageEndpoint(MESSAGE_ENDPOINT).build(); - } - - @Bean - public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - } - - private AnnotationConfigWebApplicationContext appContext; - - @Override - protected McpServer.SyncSpecification prepareSyncServerBuilder() { - return McpServer.sync(createMcpTransportProvider()); - } - - private WebMvcSseServerTransportProvider createMcpTransportProvider() { - // Set up Tomcat first - tomcat = new Tomcat(); - tomcat.setPort(PORT); - - // Set Tomcat base directory to java.io.tmpdir to avoid permission issues - String baseDir = System.getProperty("java.io.tmpdir"); - tomcat.setBaseDir(baseDir); - - // Use the same directory for document base - Context context = tomcat.addContext("", baseDir); - - // Create and configure Spring WebMvc context - appContext = new AnnotationConfigWebApplicationContext(); - appContext.register(TestConfig.class); - appContext.setServletContext(context.getServletContext()); - appContext.refresh(); - - // Get the transport from Spring context - transportProvider = appContext.getBean(WebMvcSseServerTransportProvider.class); - - // Create DispatcherServlet with our Spring context - DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext); - - // Add servlet to Tomcat and get the wrapper - var wrapper = Tomcat.addServlet(context, "dispatcherServlet", dispatcherServlet); - wrapper.setLoadOnStartup(1); - context.addServletMappingDecoded("/*", "dispatcherServlet"); - - try { - tomcat.start(); - tomcat.getConnector(); // Create and start the connector - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - return transportProvider; - } - - @Override - protected void onStart() { - } - - @Override - protected void onClose() { - if (transportProvider != null) { - transportProvider.closeGracefully().block(); - } - if (appContext != null) { - appContext.close(); - } - if (tomcat != null) { - try { - tomcat.stop(); - tomcat.destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcStatelessIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcStatelessIntegrationTests.java deleted file mode 100644 index 8c7b0a85e..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcStatelessIntegrationTests.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2024 - 2024 the original author or authors. - */ -package io.modelcontextprotocol.server; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Duration; -import java.util.stream.Stream; - -import org.apache.catalina.LifecycleException; -import org.apache.catalina.LifecycleState; -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.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerResponse; - -import io.modelcontextprotocol.AbstractStatelessIntegrationTests; -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; -import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport; -import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification; -import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification; -import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport; -import reactor.core.scheduler.Schedulers; - -@Timeout(15) -class WebMvcStatelessIntegrationTests extends AbstractStatelessIntegrationTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private WebMvcStatelessServerTransport mcpServerTransport; - - static Stream clientsForTesting() { - return Stream.of(Arguments.of("httpclient"), Arguments.of("webflux")); - } - - @Configuration - @EnableWebMvc - static class TestConfig { - - @Bean - public WebMvcStatelessServerTransport webMvcStatelessServerTransport() { - - return WebMvcStatelessServerTransport.builder().messageEndpoint(MESSAGE_ENDPOINT).build(); - - } - - @Bean - public RouterFunction routerFunction(WebMvcStatelessServerTransport statelessServerTransport) { - return statelessServerTransport.getRouterFunction(); - } - - } - - private TomcatTestUtil.TomcatServer tomcatServer; - - @Override - protected StatelessAsyncSpecification prepareAsyncServerBuilder() { - return McpServer.async(this.mcpServerTransport); - } - - @Override - protected StatelessSyncSpecification prepareSyncServerBuilder() { - return McpServer.sync(this.mcpServerTransport); - } - - @Override - protected void prepareClients(int port, String mcpEndpoint) { - - clientBuilders.put("httpclient", McpClient - .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint(mcpEndpoint).build()) - .requestTimeout(Duration.ofHours(10))); - - clientBuilders.put("webflux", - McpClient - .sync(WebClientStreamableHttpTransport - .builder(WebClient.builder().baseUrl("http://localhost:" + port)) - .endpoint(mcpEndpoint) - .build()) - .requestTimeout(Duration.ofHours(10))); - } - - @BeforeEach - public void before() { - - tomcatServer = TomcatTestUtil.createTomcatServer("", PORT, TestConfig.class); - - try { - tomcatServer.tomcat().start(); - assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED); - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - prepareClients(PORT, MESSAGE_ENDPOINT); - - // Get the transport from Spring context - this.mcpServerTransport = tomcatServer.appContext().getBean(WebMvcStatelessServerTransport.class); - - } - - @AfterEach - public void after() { - reactor.netty.http.HttpResources.disposeLoopsAndConnections(); - if (this.mcpServerTransport != null) { - this.mcpServerTransport.closeGracefully().block(); - } - Schedulers.shutdownNow(); - if (tomcatServer.appContext() != null) { - tomcatServer.appContext().close(); - } - if (tomcatServer.tomcat() != null) { - try { - tomcatServer.tomcat().stop(); - tomcatServer.tomcat().destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcStreamableIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcStreamableIntegrationTests.java deleted file mode 100644 index cb7b4a2a0..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcStreamableIntegrationTests.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2024 - 2024 the original author or authors. - */ -package io.modelcontextprotocol.server; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Duration; -import java.util.Map; -import java.util.stream.Stream; - -import org.apache.catalina.LifecycleException; -import org.apache.catalina.LifecycleState; -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.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.servlet.function.ServerRequest; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerResponse; - -import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests; -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.AsyncSpecification; -import io.modelcontextprotocol.server.McpServer.SyncSpecification; -import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider; -import reactor.core.scheduler.Schedulers; - -@Timeout(15) -class WebMvcStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private WebMvcStreamableServerTransportProvider mcpServerTransportProvider; - - static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = r -> McpTransportContext - .create(Map.of("important", "value")); - - static Stream clientsForTesting() { - return Stream.of(Arguments.of("httpclient"), Arguments.of("webflux")); - } - - @Configuration - @EnableWebMvc - static class TestConfig { - - @Bean - public WebMvcStreamableServerTransportProvider webMvcStreamableServerTransportProvider() { - return WebMvcStreamableServerTransportProvider.builder() - .contextExtractor(TEST_CONTEXT_EXTRACTOR) - .mcpEndpoint(MESSAGE_ENDPOINT) - .build(); - } - - @Bean - public RouterFunction routerFunction( - WebMvcStreamableServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - } - - private TomcatTestUtil.TomcatServer tomcatServer; - - @BeforeEach - public void before() { - - tomcatServer = TomcatTestUtil.createTomcatServer("", PORT, TestConfig.class); - - try { - tomcatServer.tomcat().start(); - assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED); - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - clientBuilders - .put("httpclient", - McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) - .endpoint(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(MESSAGE_ENDPOINT) - .build())); - - // Get the transport from Spring context - this.mcpServerTransportProvider = tomcatServer.appContext() - .getBean(WebMvcStreamableServerTransportProvider.class); - - } - - @Override - protected AsyncSpecification prepareAsyncServerBuilder() { - return McpServer.async(this.mcpServerTransportProvider); - } - - @Override - protected SyncSpecification prepareSyncServerBuilder() { - return McpServer.sync(this.mcpServerTransportProvider); - } - - @AfterEach - public void after() { - reactor.netty.http.HttpResources.disposeLoopsAndConnections(); - if (mcpServerTransportProvider != null) { - mcpServerTransportProvider.closeGracefully().block(); - } - Schedulers.shutdownNow(); - if (tomcatServer.appContext() != null) { - tomcatServer.appContext().close(); - } - if (tomcatServer.tomcat() != null) { - try { - tomcatServer.tomcat().stop(); - tomcatServer.tomcat().destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } - } - - @Override - protected void prepareClients(int port, String mcpEndpoint) { - - clientBuilders.put("httpclient", McpClient - .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint(mcpEndpoint).build()) - .requestTimeout(Duration.ofHours(10))); - - clientBuilders.put("webflux", - McpClient - .sync(WebClientStreamableHttpTransport - .builder(WebClient.builder().baseUrl("http://localhost:" + port)) - .endpoint(mcpEndpoint) - .build()) - .requestTimeout(Duration.ofHours(10))); - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java deleted file mode 100644 index 89fd3d75f..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2025 - 2025 the original author or authors. - */ - -package io.modelcontextprotocol.server.transport; - -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; -import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.json.McpJsonDefaults; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.TestUtil; -import io.modelcontextprotocol.server.TomcatTestUtil; -import io.modelcontextprotocol.spec.McpSchema; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.LifecycleState; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.servlet.function.ServerResponse; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Integration tests for WebMvcSseServerTransportProvider - * - * @author lance - */ -class WebMvcSseServerTransportProviderTests { - - private static final int PORT = TestUtil.findAvailablePort(); - - private static final String CUSTOM_CONTEXT_PATH = ""; - - private static final String MESSAGE_ENDPOINT = "/mcp/message"; - - private WebMvcSseServerTransportProvider mcpServerTransportProvider; - - McpClient.SyncSpec clientBuilder; - - private TomcatTestUtil.TomcatServer tomcatServer; - - @BeforeEach - public void before() { - tomcatServer = TomcatTestUtil.createTomcatServer(CUSTOM_CONTEXT_PATH, PORT, TestConfig.class); - - try { - tomcatServer.tomcat().start(); - assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED); - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } - - HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder("http://localhost:" + PORT) - .sseEndpoint(WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT) - .build(); - - clientBuilder = McpClient.sync(transport); - mcpServerTransportProvider = tomcatServer.appContext().getBean(WebMvcSseServerTransportProvider.class); - } - - @Test - void validBaseUrl() { - McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").build(); - try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) - .build()) { - assertThat(client.initialize()).isNotNull(); - } - } - - @AfterEach - public void after() { - if (mcpServerTransportProvider != null) { - mcpServerTransportProvider.closeGracefully().block(); - } - if (tomcatServer.appContext() != null) { - tomcatServer.appContext().close(); - } - if (tomcatServer.tomcat() != null) { - try { - tomcatServer.tomcat().stop(); - tomcatServer.tomcat().destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } - } - - @Configuration - @EnableWebMvc - static class TestConfig { - - @Bean - public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() { - - return WebMvcSseServerTransportProvider.builder() - .baseUrl("http://localhost:" + PORT + "/") - .messageEndpoint(MESSAGE_ENDPOINT) - .sseEndpoint(WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT) - .jsonMapper(McpJsonDefaults.getMapper()) - .contextExtractor(req -> McpTransportContext.EMPTY) - .build(); - } - - @Bean - public RouterFunction routerFunction(WebMvcSseServerTransportProvider transportProvider) { - return transportProvider.getRouterFunction(); - } - - } - -} diff --git a/mcp-spring/mcp-spring-webmvc/src/test/resources/logback.xml b/mcp-spring/mcp-spring-webmvc/src/test/resources/logback.xml deleted file mode 100644 index d4ccbc173..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/resources/logback.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - - - - - - - - - - - diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java index 338eaf931..8fb8093ac 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java @@ -45,7 +45,8 @@ public abstract class AbstractMcpAsyncClientResiliencyTests { private static final Logger logger = LoggerFactory.getLogger(AbstractMcpAsyncClientResiliencyTests.class); static Network network = Network.newNetwork(); - static String host = "http://localhost:3001"; + + public static String host = "http://localhost:3001"; @SuppressWarnings("resource") static GenericContainer container = new GenericContainer<>("docker.io/node:lts-alpine3.23") diff --git a/mkdocs.yml b/mkdocs.yml index e4975cf3e..3e27c3fb5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -39,18 +39,18 @@ theme: - content.tabs.link nav: - - Getting Started: - - Overview: index.md + - Documentation: + - Overview: overview.md - Quickstart: quickstart.md - - MCP Components: - - MCP Client: client.md - - MCP Server: server.md + - MCP Components: + - MCP Client: client.md + - MCP Server: server.md - Contributing: - Contributing Guide: contribute.md - Documentation: development.md - - Blog: - - blog/index.md - API Reference: https://javadoc.io/doc/io.modelcontextprotocol.sdk/mcp-core/latest + - News: + - blog/index.md markdown_extensions: - admonition diff --git a/pom.xml b/pom.xml index cdfc7b679..4c2f36cb8 100644 --- a/pom.xml +++ b/pom.xml @@ -108,8 +108,6 @@ mcp-core mcp-json-jackson2 mcp-json-jackson3 - mcp-spring/mcp-spring-webflux - mcp-spring/mcp-spring-webmvc mcp-test conformance-tests