diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a07b6a840..c903204cd 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Questions and Community Support - url: https://stackoverflow.com/questions/tagged/spring-ai-mcp - about: Please ask and answer questions on StackOverflow with the spring-ai tag + url: https://stackoverflow.com/questions/tagged/mcp-java-sdk + about: Please ask and answer questions on StackOverflow with the mcp-java-sdk tag diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..c25de745b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: monthly + - package-ecosystem: 'maven' + directory: '/' + schedule: + interval: monthly + open-pull-requests-limit: 10 + ignore: + # Freeze production dependencies of mcp-core + - dependency-name: 'org.slf4j:slf4j-api' + - dependency-name: 'com.fasterxml.jackson.core:jackson-annotations' + - dependency-name: 'tools.jackson.core:jackson-databind' + - dependency-name: 'io.projectreactor:reactor-bom' + - dependency-name: 'io.projectreactor:reactor-core' + - dependency-name: 'jakarta.servlet:jakarta.servlet-api' + # mcp-json-jackson2 and mcp-json-jackson3 dependencies + - dependency-name: 'com.fasterxml.jackson.core:jackson-databind' + - dependency-name: 'com.networknt:json-schema-validator' \ No newline at end of file diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 82db97f76..efd06938f 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -57,6 +57,48 @@ jobs: uses: modelcontextprotocol/conformance@v0.1.11 with: mode: client - command: 'java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar' + command: 'java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-*-SNAPSHOT.jar' scenario: ${{ matrix.scenario }} expected-failures: ./conformance-tests/conformance-baseline.yml + + auth: + name: Auth Conformance + runs-on: ubuntu-latest + strategy: + matrix: + scenario: + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/basic-cimd + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/scope-step-up + - auth/scope-retry-limit + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/pre-registration + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Build client + run: mvn clean install -DskipTests + + - name: Run conformance test + uses: modelcontextprotocol/conformance@v0.1.15 + with: + node-version: '22' # see https://github.com/modelcontextprotocol/conformance/pull/162 + mode: client + command: 'java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-*-SNAPSHOT.jar' + scenario: ${{ matrix.scenario }} + expected-failures: ./conformance-tests/conformance-baseline.yml \ No newline at end of file diff --git a/DEPENDENCY_POLICY.md b/DEPENDENCY_POLICY.md new file mode 100644 index 000000000..5714a6b57 --- /dev/null +++ b/DEPENDENCY_POLICY.md @@ -0,0 +1,26 @@ +# Dependency Policy + +As a library consumed by downstream projects, the MCP Java SDK takes a conservative approach to dependency updates. Dependencies are kept stable unless there is a specific reason to update, such as a security vulnerability, a bug fix, or a need for new functionality. + +## Update Triggers + +Dependencies are updated when: + +- A **security vulnerability** is disclosed (via GitHub security alerts). +- A bug in a dependency directly affects the SDK. +- A new dependency feature is needed for SDK development. +- A dependency drops support for a Java version the SDK still targets. + +Routine version bumps without a clear motivation are avoided to minimize churn for downstream consumers. + +## What We Don't Do + +The SDK does not run scheduled version bumps for production Maven dependencies. Updating a dependency can force downstream consumers to adopt that update transitively, which can be disruptive for projects with strict dependency policies. + +Dependencies are only updated when there is a concrete reason, not simply because a newer version is available. + +## Automated Tooling + +- **GitHub security updates** are enabled at the repository level and automatically open pull requests for Maven packages with known vulnerabilities. This is a GitHub repo setting, separate from the `dependabot.yml` configuration. +- **GitHub Actions versions** are kept up to date via Dependabot on a monthly schedule (see `.github/dependabot.yml`). +- **Maven dependencies** are monitored via Dependabot on a monthly schedule for non-production updates only (see `.github/dependabot.yml`). diff --git a/MIGRATION-1.0.md b/MIGRATION-1.0.md new file mode 100644 index 000000000..d1ef0fae8 --- /dev/null +++ b/MIGRATION-1.0.md @@ -0,0 +1,300 @@ +# MCP Java SDK Migration Guide: 0.18.1 → 1.0.0 + +This document covers the breaking changes between **0.18.1** and **1.0.0** of the MCP Java SDK. All items listed here were already deprecated (with `@Deprecated` or `@Deprecated(forRemoval = true)`) in 0.18.1 and are now removed. + +> **If you are on a version earlier than 0.18.1**, upgrade progressively to **0.18.1** first. That release already provides the replacement APIs described below alongside the deprecated ones, so you can resolve all deprecation warnings before moving to 1.0.0. Many types and APIs that existed in older 0.x versions (e.g., `ClientMcpTransport`, `ServerMcpTransport`, `DefaultMcpSession`, `StdioServerTransport`, `HttpServletSseServerTransport`, `FlowSseClient`) were already removed well before 0.18.1 and are not covered here. + +--- + +## 1. The `mcp` aggregator module now defaults to Jackson 3 + +The module structure (`mcp-core`, `mcp-json-jackson2`, `mcp-json-jackson3`, `mcp`) is unchanged. What changes is the default JSON binding in the `mcp` convenience artifact: + +| Version | `mcp` artifact includes | +|---|---| +| 0.18.1 | `mcp-core` + `mcp-json-jackson2` | +| 1.0.0 | `mcp-core` + `mcp-json-jackson3` | + +If your project uses **Jackson 2** (the `com.fasterxml.jackson` 2.x line), stop depending on the `mcp` aggregator and depend on the individual modules instead: + +```xml + + io.modelcontextprotocol.sdk + mcp-core + 1.0.0-RC3 + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + 1.0.0-RC3 + +``` + +If you are ready to adopt **Jackson 3**, you can simply continue using the `mcp` aggregator: + +```xml + + io.modelcontextprotocol.sdk + mcp + 1.0.0-RC3 + +``` + +### Deprecated `io.modelcontextprotocol.json.jackson` package removed + +In `mcp-json-jackson2`, the classes under the old `io.modelcontextprotocol.json.jackson` package (deprecated in 0.18.1) have been removed. Use the equivalent classes under `io.modelcontextprotocol.json.jackson2`: + +| Removed (old package) | Replacement (already available in 0.18.1) | +|---|---| +| `io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper` | `io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper` | +| `io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapperSupplier` | `io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier` | +| `io.modelcontextprotocol.json.schema.jackson.DefaultJsonSchemaValidator` | `io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator` | +| `io.modelcontextprotocol.json.schema.jackson.JacksonJsonSchemaValidatorSupplier` | `io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier` | + +--- + +## 2. Spring transport modules (`mcp-spring-webflux`, `mcp-spring-webmvc`) + +These modules have been moved to the **Spring AI** project starting with Spring AI 2.0. The artifact names remain the same but the **Maven group has changed**: + +| 0.18.1 (MCP Java SDK) | 1.0.0+ (Spring AI 2.0) | +|---|---| +| `io.modelcontextprotocol.sdk:mcp-spring-webflux` | `org.springframework.ai:mcp-spring-webflux` | +| `io.modelcontextprotocol.sdk:mcp-spring-webmvc` | `org.springframework.ai:mcp-spring-webmvc` | + +Update your dependency coordinates: + +```xml + + + io.modelcontextprotocol.sdk + mcp-spring-webflux + 0.18.1 + + + + + org.springframework.ai + mcp-spring-webflux + ${spring-ai.version} + +``` + +The Java package names and class names within these artifacts are unchanged — no source code modifications are needed beyond updating the dependency coordinates. + +--- + +## 3. Tool handler signature — `tool()` removed, use `toolCall()` + +The `tool()` method on the `McpServer` builder (both sync and async variants) has been removed. It was deprecated in 0.18.1 in favor of `toolCall()`, which accepts a handler that receives the full `CallToolRequest` instead of a raw `Map`. + +#### Before (deprecated, removed in 1.0.0): + +```java +McpServer.sync(transportProvider) + .tool( + myTool, + (exchange, args) -> new CallToolResult(List.of(new TextContent("Result: " + calculate(args))), false) + ) + .build(); +``` + +#### After (already available in 0.18.1): + +```java +McpServer.sync(transportProvider) + .toolCall( + myTool, + (exchange, request) -> CallToolResult.builder() + .content(List.of(new TextContent("Result: " + calculate(request.arguments())))) + .isError(false) + .build() + ) + .build(); +``` + +--- + +## 4. `AsyncToolSpecification` / `SyncToolSpecification` — `call` field removed + +The deprecated `call` record component (which accepted `Map`) has been removed from both `AsyncToolSpecification` and `SyncToolSpecification`. Only `callHandler` (which accepts `CallToolRequest`) remains. + +The deprecated constructors that accepted a `call` function have also been removed. Use the builder: + +```java +McpServerFeatures.AsyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, request) -> Mono.just( + CallToolResult.builder() + .content(List.of(new TextContent("Done"))) + .build())) + .build(); +``` + +--- + +## 5. Content types — deprecated `audience`/`priority` constructors and accessors removed + +`TextContent`, `ImageContent`, and `EmbeddedResource` previously had constructors and accessors that took inline `List audience` and `Double priority` parameters. These were deprecated in favor of the `Annotations` record. The deprecated forms are now removed. + +#### Before (deprecated, removed in 1.0.0): + +```java +new TextContent(List.of(Role.USER), 0.8, "Hello world") +textContent.audience() // deprecated accessor +textContent.priority() // deprecated accessor +``` + +#### After (already available in 0.18.1): + +```java +new TextContent(new Annotations(List.of(Role.USER), 0.8), "Hello world") +textContent.annotations().audience() +textContent.annotations().priority() +``` + +The simple `new TextContent("text")` constructor continues to work. + +--- + +## 6. `CallToolResult` and `Resource` — deprecated constructors removed + +The constructors on `CallToolResult` and `Resource` that were deprecated in 0.18.1 have been removed. Use the builders instead. + +#### `CallToolResult` + +```java +// Removed: +new CallToolResult(List.of(new TextContent("result")), false); +new CallToolResult("result text", false); +new CallToolResult(content, isError, structuredContent); + +// Use instead: +CallToolResult.builder() + .content(List.of(new TextContent("result"))) + .isError(false) + .build(); +``` + +#### `Resource` + +```java +// Removed: +new Resource(uri, name, description, mimeType, annotations); +new Resource(uri, name, title, description, mimeType, size, annotations); + +// Use instead: +Resource.builder() + .uri(uri) + .name(name) + .title(title) + .description(description) + .mimeType(mimeType) + .size(size) + .annotations(annotations) + .build(); +``` + +--- + +## 7. `McpError(Object)` constructor removed + +The deprecated `McpError(Object error)` constructor, which was commonly used as `new McpError("message string")`, has been removed. Construct `McpError` instances using the builder with a JSON-RPC error code: + +```java +// Removed: +throw new McpError("Something went wrong"); + +// Use instead: +throw McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Something went wrong") + .build(); +``` + +Additionally, several places in the SDK that previously threw `McpError` for validation or state-checking purposes now throw standard Java exceptions (`IllegalStateException`, `IllegalArgumentException`). If you were catching `McpError` in those scenarios, update your catch blocks accordingly. + +--- + +## 8. `McpSchema.LATEST_PROTOCOL_VERSION` constant removed + +The deprecated `McpSchema.LATEST_PROTOCOL_VERSION` constant has been removed. Use the `ProtocolVersions` interface directly: + +```java +// Removed: +McpSchema.LATEST_PROTOCOL_VERSION + +// Use instead: +ProtocolVersions.MCP_2025_11_25 +``` + +--- + +## 9. Deprecated session constructors and inner interfaces removed + +The following deprecated constructors and inner interfaces, all of which already had replacements available in 0.18.1, have been removed: + +### `McpServerSession` + +| Removed | Replacement (available since 0.18.1) | +|---|---| +| Constructor with `InitNotificationHandler` parameter | Constructor without `InitNotificationHandler` — use `McpInitRequestHandler` in the map | +| `McpServerSession.InitRequestHandler` (inner interface) | `McpInitRequestHandler` (top-level interface) | +| `McpServerSession.RequestHandler` (inner interface) | `McpRequestHandler` (top-level interface) | +| `McpServerSession.NotificationHandler` (inner interface) | `McpNotificationHandler` (top-level interface) | + +### `McpClientSession` + +| Removed | Replacement (available since 0.18.1) | +|---|---| +| Constructor without `connectHook` parameter | Constructor that accepts a `Function, ? extends Publisher> connectHook` | + +### `McpAsyncServerExchange` + +| Removed | Replacement (available since 0.18.1) | +|---|---| +| Constructor `McpAsyncServerExchange(McpSession, ClientCapabilities, Implementation)` | Constructor `McpAsyncServerExchange(String, McpLoggableSession, ClientCapabilities, Implementation, McpTransportContext)` | + +--- + +## 10. `McpAsyncServer.loggingNotification()` / `McpSyncServer.loggingNotification()` removed + +The `loggingNotification(LoggingMessageNotification)` methods on `McpAsyncServer` and `McpSyncServer` were deprecated because they incorrectly broadcast to all connected clients. They have been removed. Use the per-session exchange method instead: + +```java +// Removed: +server.loggingNotification(notification); + +// Use instead (inside a handler with access to the exchange): +exchange.loggingNotification(notification); +``` + +--- + +## 11. `HttpClientSseClientTransport.Builder` — deprecated constructor removed + +The deprecated `new HttpClientSseClientTransport.Builder(String baseUri)` constructor has been removed. Use the static factory method: + +```java +// Removed: +new HttpClientSseClientTransport.Builder("http://localhost:8080") + +// Use instead: +HttpClientSseClientTransport.builder("http://localhost:8080") +``` + +--- + +## Summary checklist + +Before upgrading to 1.0.0, verify that your 0.18.1 build has **zero deprecation warnings** related to the MCP SDK. Every removal in 1.0.0 was preceded by a deprecation in 0.18.1 with a pointer to the replacement. Once you are clean on 0.18.1: + +1. Update your dependency versions — either bump the `mcp-bom` version, or bump the specific module dependencies you use (e.g., `mcp-core`, `mcp-json-jackson2`). If you were relying on the `mcp` aggregator, note it now pulls in Jackson 3 — switch to `mcp-core` + `mcp-json-jackson2` if you need to stay on Jackson 2. +2. Replace `io.modelcontextprotocol.sdk:mcp-spring-webflux` / `mcp-spring-webmvc` with `org.springframework.ai:mcp-spring-webflux` / `mcp-spring-webmvc`. +3. If you use the `mcp-json-jackson2` module, update imports from `io.modelcontextprotocol.json.jackson` to `io.modelcontextprotocol.json.jackson2` (and similarly for the schema validator package). +4. Compile and verify — no further source changes should be needed. + +--- + +## Need help? + +If you run into issues during migration or have questions, please open an issue or start a discussion in the [MCP Java SDK GitHub repository](https://github.com/modelcontextprotocol/java-sdk). 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/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..b5b7dc4d7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,45 @@ +# Roadmap + +## Spec Implementation Tracking + +The SDK tracks implementation of MCP spec components via GitHub Projects, with a dedicated project board for each spec revision. For example, see the [2025-11-25 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/26/views/1). + +## Current Focus Areas + +### 2025-11-25 Spec Implementation + +The Java SDK is actively implementing the [2025-11-25 MCP specification revision](https://github.com/orgs/modelcontextprotocol/projects/26/views/1). + +Key features in this revision include: + +- **Tasks**: Experimental support for tracking durable requests with polling and deferred result retrieval +- **Tool calling in sampling**: Support for `tools` and `toolChoice` parameters +- **URL mode elicitation**: Client-side URL elicitation requests +- **Icons metadata**: Servers can expose icons for tools, resources, resource templates, and prompts +- **Enhanced schemas**: JSON Schema 2020-12 as default, improved enum support, default values for elicitation +- **Security improvements**: Updated security best practices, enhanced authorization flows, enabling OAuth integrations + +See the full [changelog](https://modelcontextprotocol.io/specification/2025-11-25/changelog) for details. + +### Tier 1 SDK Support + +Once we catch up on the most recent MCP specification revision we aim to fully support all the upcoming specification features on the day of its release. + +### v1.x Development + +The Java SDK is currently in active development as v1.x, following a recent stable 1.0.0 release. The SDK provides: + +- MCP protocol implementation +- Synchronous and asynchronous programming models +- Multiple transport options (STDIO, HTTP/SSE, Servlet) +- Pluggable JSON serialization (Jackson 2 and Jackson 3) + +Development is tracked via [GitHub Issues](https://github.com/modelcontextprotocol/java-sdk/issues) and [GitHub Projects](https://github.com/orgs/modelcontextprotocol/projects). + +### Future Versions + +Major version updates will align with MCP specification changes and breaking API changes as needed. The SDK is designed to evolve with the Java ecosystem, including: + +- Virtual Threads and Structured Concurrency support +- Additional transport implementations +- Performance optimizations diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 000000000..331c6d05e --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,46 @@ +# Versioning Policy + +The MCP Java SDK (`io.modelcontextprotocol.sdk`) follows [Semantic Versioning 2.0.0](https://semver.org/). + +## Version Format + +`MAJOR.MINOR.PATCH` + +- **MAJOR**: Incremented for breaking changes (see below). +- **MINOR**: Incremented for new features that are backward-compatible. +- **PATCH**: Incremented for backward-compatible bug fixes. + +## What Constitutes a Breaking Change + +The following changes are considered breaking and require a major version bump: + +- Removing or renaming a public API (class, interface, method, or constant). +- Changing the signature of a public method in a way that breaks existing callers (removing parameters, changing required/optional status, changing types). +- Removing or renaming a public interface method or field. +- Changing the behavior of an existing API in a way that breaks documented contracts. +- Dropping support for a Java LTS version. +- Removing support for a transport type. +- Changes to the MCP protocol version that require client/server code changes. +- Removing a module from the SDK. + +The following are **not** considered breaking: + +- Adding new methods with default implementations to interfaces. +- Adding new public APIs, classes, interfaces, or methods. +- Adding new optional parameters to existing methods (through method overloading). +- Bug fixes that correct behavior to match documented intent. +- Internal refactoring that does not affect the public API. +- Adding support for new MCP spec features. +- Changes to test dependencies or build tooling. +- Adding new modules to the SDK. + +## How Breaking Changes Are Communicated + +1. **Changelog**: All breaking changes are documented in the GitHub release notes with migration instructions. +2. **Deprecation**: When feasible, APIs are deprecated for at least one minor release before removal using `@Deprecated` annotations, which surface warnings through Java tooling and IDEs. +3. **Migration guide**: Major version releases include a migration guide describing what changed and how to update. +4. **PR labels**: Pull requests containing breaking changes are labeled with `breaking change`. + +## Maven Coordinates + +All SDK modules share the same version number and are released together. The BOM (`mcp-bom`) provides dependency management for all SDK modules to ensure version consistency. diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index 7be75e6e5..19e74330c 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -2,8 +2,9 @@ ## Summary -**Server Tests:** 37/40 passed (92.5%) +**Server Tests:** 37/40 passed (92.5%) **Client Tests:** 3/4 scenarios passed (9/10 checks passed) +**Auth Tests:** 12/14 scenarios fully passing (178 passed, 1 failed, 1 warning, 85.7% scenarios, 98.9% checks) ## Server Test Results @@ -20,7 +21,7 @@ ### Failing (3/40) 1. **resources-subscribe** - Not implemented in SDK -2. **resources-unsubscribe** - Not implemented in SDK +2. **resources-unsubscribe** - Not implemented in SDK ## Client Test Results @@ -32,17 +33,45 @@ ### Partially Passing (1/4 scenarios, 1/2 checks) -- **sse-retry (1/2 + 1 warning):** +- **sse-retry (1/2 + 1 warning):** - ✅ Reconnects after stream closure - ❌ Does not respect retry timing - ⚠️ Does not send Last-Event-ID header (SHOULD requirement) **Issue:** Client treats `retry:` SSE field as invalid instead of parsing it for reconnection timing. +## Auth Test Results (Spring HTTP Client) + +**Status: 178 passed, 1 failed, 1 warning across 14 scenarios** + +Uses the `client-spring-http-client` module with Spring Security OAuth2 and the [mcp-client-security](https://github.com/springaicommunity/mcp-client-security) library. + +### Fully Passing (12/14 scenarios) + +- **auth/metadata-default (12/12):** Default metadata discovery +- **auth/metadata-var1 (12/12):** Metadata discovery variant 1 +- **auth/metadata-var2 (12/12):** Metadata discovery variant 2 +- **auth/metadata-var3 (12/12):** Metadata discovery variant 3 +- **auth/scope-from-www-authenticate (13/13):** Scope extraction from WWW-Authenticate header +- **auth/scope-from-scopes-supported (13/13):** Scope extraction from scopes_supported +- **auth/scope-omitted-when-undefined (13/13):** Scope omitted when not defined +- **auth/scope-retry-limit (11/11):** Scope retry limit handling +- **auth/token-endpoint-auth-basic (17/17):** Token endpoint with HTTP Basic auth +- **auth/token-endpoint-auth-post (17/17):** Token endpoint with POST body auth +- **auth/token-endpoint-auth-none (17/17):** Token endpoint with no client auth +- **auth/pre-registration (6/6):** Pre-registered client credentials flow + +### Partially Passing (2/14 scenarios) + +- **auth/basic-cimd (12/12 + 1 warning):** Basic Client-Initiated Metadata Discovery — all checks pass, minor warning +- **auth/scope-step-up (11/12):** Scope step-up challenge — 1 failure, client does not fully handle scope escalation after initial authorization + ## Known Limitations 1. **Resource Subscriptions:** SDK doesn't implement `resources/subscribe` and `resources/unsubscribe` handlers 2. **Client SSE Retry:** Client doesn't parse or respect the `retry:` field, reconnects immediately, and doesn't send Last-Event-ID header +3. **Auth Scope Step-Up:** Client does not fully handle scope step-up challenges where the server requests additional scopes after initial authorization +4. **Auth Basic CIMD:** Minor conformance warning in the basic Client-Initiated Metadata Discovery flow ## Running Tests @@ -70,11 +99,26 @@ for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-re done ``` +### Auth (Spring HTTP Client) + +Ensure you run with the conformance testing suite `0.1.15` or higher. + +```bash +# Build +cd conformance-tests/client-spring-http-client +../../mvnw clean package -DskipTests + +# Run auth suite +npx @modelcontextprotocol/conformance@0.1.15 client \ + --spec-version 2025-11-25 \ + --command "java -jar target/client-spring-http-client-0.18.0-SNAPSHOT.jar" \ + --suite auth +``` + ## Recommendations ### High Priority 1. Fix client SSE retry field handling in `HttpClientStreamableHttpTransport` 2. Implement resource subscription handlers in `McpStatelessAsyncServer` - -### Medium Priority -3. Add Host/Origin validation in `HttpServletStreamableServerTransportProvider` for DNS rebinding protection +3. Implement CIMD +4. Implement scope step up diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index d5a1e843a..f30361438 100644 --- a/conformance-tests/client-jdk-http-client/pom.xml +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT client-jdk-http-client jar @@ -20,11 +20,15 @@ git@github.com/modelcontextprotocol/java-sdk.git + + true + + io.modelcontextprotocol.sdk mcp - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT diff --git a/conformance-tests/client-spring-http-client/README.md b/conformance-tests/client-spring-http-client/README.md new file mode 100644 index 000000000..876a86e1d --- /dev/null +++ b/conformance-tests/client-spring-http-client/README.md @@ -0,0 +1,124 @@ +# MCP Conformance Tests - Spring HTTP Client (Auth Suite) + +This module provides a conformance test client implementation for the Java MCP SDK's **auth** suite. + +OAuth2 support is not implemented in the SDK itself, but we provide hooks to implement the Authorization section of the specification. One such implementation is done in Spring, with Sprign AI and the [mcp-client-security](https://github.com/springaicommunity/mcp-client-security) library. + +This is a Spring web application, we interact with it through a normal HTTP-client that follows redirects and performs OAuth2 authorization flows. + +## Overview + +The conformance test client is designed to work with the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance). It validates that the Java MCP SDK client, combined with Spring Security's OAuth2 support, properly implements the MCP authorization specification. + +Test with @modelcontextprotocol/conformance@0.1.15. + +## Conformance Test Results + +**Status: 178 passed, 1 failed, 1 warning across 14 scenarios** + +| Scenario | Result | Details | +|---|---|---| +| auth/metadata-default | ✅ Pass | 12/12 | +| auth/metadata-var1 | ✅ Pass | 12/12 | +| auth/metadata-var2 | ✅ Pass | 12/12 | +| auth/metadata-var3 | ✅ Pass | 12/12 | +| auth/basic-cimd | ⚠️ Warning | 12/12 passed, 1 warning | +| auth/scope-from-www-authenticate | ✅ Pass | 13/13 | +| auth/scope-from-scopes-supported | ✅ Pass | 13/13 | +| auth/scope-omitted-when-undefined | ✅ Pass | 13/13 | +| auth/scope-step-up | ❌ Fail | 11/12 (1 failed) | +| auth/scope-retry-limit | ✅ Pass | 11/11 | +| auth/token-endpoint-auth-basic | ✅ Pass | 17/17 | +| auth/token-endpoint-auth-post | ✅ Pass | 17/17 | +| auth/token-endpoint-auth-none | ✅ Pass | 17/17 | +| auth/pre-registration | ✅ Pass | 6/6 | + +See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for the full project validation results. + +## Architecture + +The client is a Spring Boot application that reads test scenarios from environment variables and accepts the server URL as a command-line argument, following the conformance framework's conventions: + +- **MCP_CONFORMANCE_SCENARIO**: Environment variable specifying which test scenario to run +- **MCP_CONFORMANCE_CONTEXT**: Environment variable with JSON context (used by `auth/pre-registration`) +- **Server URL**: Passed as the last command-line argument + +### Scenario Routing + +The application uses Spring's conditional configuration to select the appropriate scenario at startup: + +- **`DefaultConfiguration`** — Activated for all scenarios except `auth/pre-registration`. Uses the OAuth2 Authorization Code flow with dynamic client registration via `McpClientOAuth2Configurer`. +- **`PreRegistrationConfiguration`** — Activated only for `auth/pre-registration`. Uses the Client Credentials flow with pre-registered client credentials read from `MCP_CONFORMANCE_CONTEXT`. + +### Key Dependencies + +- **Spring Boot 4.0** with Spring Security OAuth2 Client +- **Spring AI MCP Client** (`spring-ai-starter-mcp-client`) +- **mcp-client-security** — Community library providing MCP-specific OAuth2 integration (metadata discovery, dynamic client registration, transport context) + +## Building + +Build the executable JAR: + +```bash +cd conformance-tests/client-spring-http-client +../../mvnw clean package -DskipTests +``` + +This creates an executable JAR at: +``` +target/client-spring-http-client-0.18.0-SNAPSHOT.jar +``` + +## Running Tests + +### Using the Conformance Framework + +Run the full auth suite: + +```bash +npx @modelcontextprotocol/conformance@0.1.15 client \ + --spec-version 2025-11-25 \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-0.18.0-SNAPSHOT.jar" \ + --suite auth +``` + +Run a single scenario: + +```bash +npx @modelcontextprotocol/conformance@0.1.15 client \ + --spec-version 2025-11-25 \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-0.18.0-SNAPSHOT.jar" \ + --scenario auth/metadata-default +``` + +Run with verbose output: + +```bash +npx @modelcontextprotocol/conformance@0.1.15 client \ + --spec-version 2025-11-25 \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-0.18.0-SNAPSHOT.jar" \ + --scenario auth/metadata-default \ + --verbose +``` + +### Manual Testing + +You can also run the client manually if you have a test server: + +```bash +export MCP_CONFORMANCE_SCENARIO=auth/metadata-default +java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-0.18.0-SNAPSHOT.jar http://localhost:3000/mcp +``` + +## Known Issues + +1. **auth/scope-step-up** (1 failure) — The client does not fully handle scope step-up challenges where the server requests additional scopes after initial authorization. +2. **auth/basic-cimd** (1 warning) — Minor conformance warning in the basic Client-Initiated Metadata Discovery flow. + +## References + +- [MCP Specification — Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [MCP Conformance Tests](https://github.com/modelcontextprotocol/conformance) +- [mcp-client-security Library](https://github.com/springaicommunity/mcp-client-security) +- [SDK Integration Guide](https://github.com/modelcontextprotocol/conformance/blob/main/SDK_INTEGRATION.md) diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml new file mode 100644 index 000000000..94923fb5c --- /dev/null +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -0,0 +1,91 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.2 + + + io.modelcontextprotocol.sdk + client-spring-http-client + 1.0.0-SNAPSHOT + jar + MCP Conformance Tests - Spring HTTP Client + Spring HTTP Client conformance tests for the Java MCP SDK + 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 + + + + 17 + 2.0.0-M2 + true + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.springframework.boot + spring-boot-starter-restclient + + + + org.springframework.ai + spring-ai-starter-mcp-client + ${spring-ai.version} + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + org.springaicommunity + mcp-client-security + 0.1.2 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + maven-central + https://repo.maven.apache.org/maven2/ + + false + + + true + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java new file mode 100644 index 000000000..00582c9f2 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java @@ -0,0 +1,99 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client; + +import java.util.Optional; + +import io.modelcontextprotocol.conformance.client.scenario.Scenario; +import org.springaicommunity.mcp.security.client.sync.oauth2.metadata.McpMetadataDiscoveryService; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.DynamicClientRegistrationService; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.InMemoryMcpClientRegistrationRepository; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +/** + * MCP Conformance Test Client - Spring HTTP Client Implementation. + * + *

+ * This client is designed to work with the MCP conformance test framework. It reads the + * test scenario from the MCP_CONFORMANCE_SCENARIO environment variable and the server URL + * from command-line arguments. + * + *

+ * It specifically tests the {@code auth} conformance suite. It requires Spring to work. + * + *

+ * Usage: java -jar client-spring-http-client.jar <server-url> + * + * @see MCP Conformance + * Test Framework + */ +@SpringBootApplication +public class ConformanceSpringClientApplication { + + public static final String REGISTRATION_ID = "default_registration"; + + public static void main(String[] args) { + SpringApplication.run(ConformanceSpringClientApplication.class, args); + } + + @Bean + McpMetadataDiscoveryService discovery() { + return new McpMetadataDiscoveryService(); + } + + @Bean + InMemoryMcpClientRegistrationRepository clientRegistrationRepository(McpMetadataDiscoveryService discovery) { + return new InMemoryMcpClientRegistrationRepository(new DynamicClientRegistrationService(), discovery); + } + + @Bean + ApplicationRunner conformanceRunner(Optional scenario, ServerUrl serverUrl) { + return args -> { + String scenarioName = System.getenv("MCP_CONFORMANCE_SCENARIO"); + if (scenarioName == null || scenarioName.isEmpty()) { + System.err.println("Error: MCP_CONFORMANCE_SCENARIO environment variable is not set"); + System.exit(1); + } + + if (scenario.isEmpty()) { + System.err.println("Unsupported scenario type"); + System.exit(1); + } + + try { + System.out.println("Executing " + scenarioName); + scenario.get().execute(serverUrl.value()); + System.exit(0); + } + catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + }; + } + + public record ServerUrl(String value) { + } + + @Bean + ServerUrl serverUrl(ApplicationArguments args) { + var nonOptionArgs = args.getNonOptionArgs(); + if (nonOptionArgs.isEmpty()) { + System.err.println("Usage: ConformanceSpringClientApplication "); + System.err.println("The server URL must be provided as a command-line argument."); + System.err.println("The MCP_CONFORMANCE_SCENARIO environment variable must be set."); + System.exit(1); + } + + return new ServerUrl(nonOptionArgs.get(nonOptionArgs.size() - 1)); + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java new file mode 100644 index 000000000..e02cfd416 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client; + +import io.modelcontextprotocol.conformance.client.scenario.Scenario; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Expose MCP client in a web environment. + */ +@RestController +class McpClientController { + + private final Scenario scenario; + + McpClientController(Scenario scenario) { + this.scenario = scenario; + } + + @GetMapping("/initialize-mcp-client") + public String execute() { + this.scenario.getMcpClient().initialize(); + return "OK"; + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java new file mode 100644 index 000000000..acf26d94e --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.configuration; + +import io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication; +import io.modelcontextprotocol.conformance.client.scenario.DefaultScenario; +import org.springaicommunity.mcp.security.client.sync.config.McpClientOAuth2Configurer; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.web.SecurityFilterChain; +import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID; + +@Configuration +@ConditionalOnExpression("#{environment['MCP_CONFORMANCE_SCENARIO'] != 'auth/pre-registration'}") +public class DefaultConfiguration { + + @Bean + DefaultScenario defaultScenario(McpClientRegistrationRepository clientRegistrationRepository, + ServletWebServerApplicationContext serverCtx, + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { + return new DefaultScenario(clientRegistrationRepository, serverCtx, oAuth2AuthorizedClientRepository); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, ConformanceSpringClientApplication.ServerUrl serverUrl) { + return http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()) + .with(new McpClientOAuth2Configurer(), + mcp -> mcp.registerMcpOAuth2Client(REGISTRATION_ID, serverUrl.value())) + .build(); + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/PreRegistrationConfiguration.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/PreRegistrationConfiguration.java new file mode 100644 index 000000000..afe03f85a --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/PreRegistrationConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.configuration; + +import io.modelcontextprotocol.conformance.client.scenario.PreRegistrationScenario; +import org.springaicommunity.mcp.security.client.sync.config.McpClientOAuth2Configurer; +import org.springaicommunity.mcp.security.client.sync.oauth2.metadata.McpMetadataDiscoveryService; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@ConditionalOnProperty(name = "mcp.conformance.scenario", havingValue = "auth/pre-registration") +public class PreRegistrationConfiguration { + + @Bean + PreRegistrationScenario defaultScenario(McpClientRegistrationRepository clientRegistrationRepository, + McpMetadataDiscoveryService mcpMetadataDiscovery, + OAuth2AuthorizedClientService oAuth2AuthorizedClientService) { + return new PreRegistrationScenario(clientRegistrationRepository, mcpMetadataDiscovery, + oAuth2AuthorizedClientService); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) { + return http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()) + .with(new McpClientOAuth2Configurer(), Customizer.withDefaults()) + .build(); + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java new file mode 100644 index 000000000..d82637de9 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.scenario; + +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.http.HttpClient; +import java.time.Duration; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.security.client.sync.AuthenticationMcpTransportContextProvider; +import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2AuthorizationCodeSyncHttpRequestCustomizer; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; + +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.web.client.RestClient; +import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID; + +public class DefaultScenario implements Scenario { + + private static final Logger log = LoggerFactory + .getLogger(DefaultScenario.class); + + private final ServletWebServerApplicationContext serverCtx; + + private final DefaultOAuth2AuthorizedClientManager authorizedClientManager; + + private McpSyncClient client; + + public DefaultScenario(McpClientRegistrationRepository clientRegistrationRepository, + ServletWebServerApplicationContext serverCtx, + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { + this.serverCtx = serverCtx; + this.authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, + oAuth2AuthorizedClientRepository); + } + + @Override + public void execute(String serverUrl) { + log.info("Executing DefaultScenario"); + var testServerUrl = "http://localhost:" + serverCtx.getWebServer().getPort(); + var testClient = buildTestClient(testServerUrl); + + var customizer = new OAuth2AuthorizationCodeSyncHttpRequestCustomizer(authorizedClientManager, REGISTRATION_ID); + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl) + .httpRequestCustomizer(customizer) + .build(); + + this.client = McpClient.sync(transport) + .transportContextProvider(new AuthenticationMcpTransportContextProvider()) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + + try { + testClient.get().uri("/initialize-mcp-client").retrieve().toBodilessEntity(); + } + finally { + // Close the client (which will close the transport) + this.client.close(); + + System.out.println("Connection closed successfully"); + } + } + + private static @NonNull RestClient buildTestClient(String testServerUrl) { + var cookieManager = new CookieManager(); + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); + var httpClient = HttpClient.newBuilder() + .cookieHandler(cookieManager) + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(); + var testClient = RestClient.builder() + .baseUrl(testServerUrl) + .requestFactory(new JdkClientHttpRequestFactory(httpClient)) + .build(); + return testClient; + } + + @Override + public McpSyncClient getMcpClient() { + if (this.client == null) { + return Scenario.super.getMcpClient(); + } + + return this.client; + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java new file mode 100644 index 000000000..8e6bbe228 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java @@ -0,0 +1,110 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.scenario; + +import java.time.Duration; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.security.client.sync.AuthenticationMcpTransportContextProvider; +import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2ClientCredentialsSyncHttpRequestCustomizer; +import org.springaicommunity.mcp.security.client.sync.oauth2.metadata.McpMetadataDiscoveryService; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ClientRegistrations; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID; + +public class PreRegistrationScenario implements Scenario { + + private static final Logger log = LoggerFactory.getLogger(PreRegistrationScenario.class); + + private final JsonMapper mapper; + + private final McpClientRegistrationRepository clientRegistrationRepository; + + private final AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager; + + private final McpMetadataDiscoveryService mcpMetadataDiscovery; + + public PreRegistrationScenario(McpClientRegistrationRepository clientRegistrationRepository, + McpMetadataDiscoveryService mcpMetadataDiscovery, OAuth2AuthorizedClientService authorizedClientService) { + this.mapper = JsonMapper.shared(); + this.clientRegistrationRepository = clientRegistrationRepository; + this.authorizedClientManager = new AuthorizedClientServiceOAuth2AuthorizedClientManager( + clientRegistrationRepository, authorizedClientService); + this.mcpMetadataDiscovery = mcpMetadataDiscovery; + } + + @Override + public void execute(String serverUrl) { + log.info("Executing PreRegistrationScenario"); + + var oauthCredentials = extractCredentialsFromContext(); + setClientRegistration(serverUrl, oauthCredentials); + + var customizer = new OAuth2ClientCredentialsSyncHttpRequestCustomizer(authorizedClientManager, REGISTRATION_ID); + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl) + .httpRequestCustomizer(customizer) + .build(); + + var client = McpClient.sync(transport) + .transportContextProvider(new AuthenticationMcpTransportContextProvider()) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + + try { + // Initialize client + client.initialize(); + + System.out.println("Successfully connected to MCP server"); + } + finally { + // Close the client (which will close the transport) + client.close(); + + System.out.println("Connection closed successfully"); + } + } + + private void setClientRegistration(String mcpServerUrl, PreRegistrationContext oauthCredentials) { + var metadata = this.mcpMetadataDiscovery.getMcpMetadata(mcpServerUrl); + var registration = ClientRegistrations + .fromIssuerLocation(metadata.protectedResourceMetadata().authorizationServers().get(0)) + .registrationId(REGISTRATION_ID) + .clientId(oauthCredentials.clientId()) + .clientSecret(oauthCredentials.clientSecret()) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .build(); + clientRegistrationRepository.addPreRegisteredClient(registration, + metadata.protectedResourceMetadata().resource()); + } + + private PreRegistrationContext extractCredentialsFromContext() { + String contextEnv = System.getenv("MCP_CONFORMANCE_CONTEXT"); + if (contextEnv == null || contextEnv.isEmpty()) { + var errorMessage = "Error: MCP_CONFORMANCE_CONTEXT environment variable is not set"; + System.err.println(errorMessage); + throw new RuntimeException(errorMessage); + } + + return mapper.readValue(contextEnv, PreRegistrationContext.class); + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + private record PreRegistrationContext(String clientId, String clientSecret) { + + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/Scenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/Scenario.java new file mode 100644 index 000000000..9054db83b --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/Scenario.java @@ -0,0 +1,17 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.scenario; + +import io.modelcontextprotocol.client.McpSyncClient; + +public interface Scenario { + + default McpSyncClient getMcpClient() { + throw new IllegalStateException("Client not set"); + } + + void execute(String serverUrl); + +} diff --git a/conformance-tests/client-spring-http-client/src/main/resources/application.properties b/conformance-tests/client-spring-http-client/src/main/resources/application.properties new file mode 100644 index 000000000..0c4a77438 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/resources/application.properties @@ -0,0 +1,4 @@ +# Server runs on random port +server.port=0 +# Disable Spring AI MCP client auto-configuration (we configure the client manually) +spring.ai.mcp.client.enabled=false diff --git a/conformance-tests/conformance-baseline.yml b/conformance-tests/conformance-baseline.yml index 920e8401c..4ab144063 100644 --- a/conformance-tests/conformance-baseline.yml +++ b/conformance-tests/conformance-baseline.yml @@ -12,3 +12,7 @@ client: # - Client does not parse or respect retry: field timing # - Client does not send Last-Event-ID header - sse-retry + # CIMD not implemented yet + - auth/basic-cimd + # Scope step up beyond initial authorization request not implemented + - auth/scope-step-up diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index 141ac6299..d1bef2a24 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT conformance-tests pom @@ -20,8 +20,13 @@ git@github.com/modelcontextprotocol/java-sdk.git + + true + + client-jdk-http-client + client-spring-http-client server-servlet diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml index 793cc7533..68da42158 100644 --- a/conformance-tests/server-servlet/pom.xml +++ b/conformance-tests/server-servlet/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT server-servlet jar @@ -20,11 +20,15 @@ git@github.com/modelcontextprotocol/java-sdk.git + + true + + io.modelcontextprotocol.sdk mcp - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT @@ -66,4 +70,4 @@ - + \ No newline at end of file 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..e7e76bc88 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" } ``` @@ -120,7 +123,7 @@ Add the BOM to your project: io.modelcontextprotocol.sdk mcp-bom - 1.0.0-RC1 + 1.0.0 pom import @@ -132,7 +135,7 @@ Add the BOM to your project: ```groovy dependencies { - implementation platform("io.modelcontextprotocol.sdk:mcp-bom:latest") + implementation platform("io.modelcontextprotocol.sdk:mcp-bom:1.0.0") //... } ``` @@ -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..fb6f3a32a 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp-bom @@ -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-core/pom.xml b/mcp-core/pom.xml index 67ed015bd..4de0fba2b 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp-core jar diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java index 66e0b9d44..be4e4cf97 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java @@ -185,19 +185,6 @@ public static class Builder { // Default constructor } - /** - * Creates a new builder with the specified base URI. - * @param baseUri the base URI of the MCP server - * @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead. - * This constructor is deprecated and will be removed or made {@code protected} or - * {@code private} in a future release. - */ - @Deprecated(forRemoval = true) - public Builder(String baseUri) { - Assert.hasText(baseUri, "baseUri must not be empty"); - this.baseUri = baseUri; - } - /** * Sets the base URI. * @param baseUri the base URI diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java index d1b55f594..660a15e6a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/DefaultMcpStatelessServerHandler.java @@ -32,7 +32,9 @@ public Mono handleRequest(McpTransportContext transpo McpSchema.JSONRPCRequest request) { McpStatelessRequestHandler requestHandler = this.requestHandlers.get(request.method()); if (requestHandler == null) { - return Mono.error(new McpError("Missing handler for request type: " + request.method())); + return Mono.error(McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) + .message("Missing handler for request type: " + request.method()) + .build()); } return requestHandler.handle(transportContext, request.params()) .map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null)) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 23285d514..32256987a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -326,7 +326,7 @@ public Mono addTool(McpServerFeatures.AsyncToolSpecification toolSpecifica if (toolSpecification.tool() == null) { return Mono.error(new IllegalArgumentException("Tool must not be null")); } - if (toolSpecification.call() == null && toolSpecification.callHandler() == null) { + if (toolSpecification.callHandler() == null) { return Mono.error(new IllegalArgumentException("Tool call handler must not be null")); } if (this.serverCapabilities.tools() == null) { @@ -869,32 +869,6 @@ private McpRequestHandler promptsGetRequestHandler() // Logging Management // --------------------------------------- - /** - * This implementation would, incorrectly, broadcast the logging message to all - * connected clients, using a single minLoggingLevel for all of them. Similar to the - * sampling and roots, the logging level should be set per client session and use the - * ServerExchange to send the logging message to the right client. - * @param loggingMessageNotification The logging message to send - * @return A Mono that completes when the notification has been sent - * @deprecated Use - * {@link McpAsyncServerExchange#loggingNotification(LoggingMessageNotification)} - * instead. - */ - @Deprecated - public Mono loggingNotification(LoggingMessageNotification loggingMessageNotification) { - - if (loggingMessageNotification == null) { - return Mono.error(new McpError("Logging message must not be null")); - } - - if (loggingMessageNotification.level().level() < minLoggingLevel.level()) { - return Mono.empty(); - } - - return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_MESSAGE, - loggingMessageNotification); - } - private McpRequestHandler setLoggerRequestHandler() { return (exchange, params) -> { return Mono.defer(() -> { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java index a15c58cd5..40a76045b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java @@ -49,28 +49,6 @@ public class McpAsyncServerExchange { public static final TypeRef OBJECT_TYPE_REF = new TypeRef<>() { }; - /** - * Create a new asynchronous exchange with the client. - * @param session The server session representing a 1-1 interaction. - * @param clientCapabilities The client capabilities that define the supported - * features and functionality. - * @param clientInfo The client implementation information. - * @deprecated Use - * {@link #McpAsyncServerExchange(String, McpLoggableSession, McpSchema.ClientCapabilities, McpSchema.Implementation, McpTransportContext)} - */ - @Deprecated - public McpAsyncServerExchange(McpSession session, McpSchema.ClientCapabilities clientCapabilities, - McpSchema.Implementation clientInfo) { - this.sessionId = null; - if (!(session instanceof McpLoggableSession)) { - throw new IllegalArgumentException("Expecting session to be a McpLoggableSession instance"); - } - this.session = (McpLoggableSession) session; - this.clientCapabilities = clientCapabilities; - this.clientInfo = clientInfo; - this.transportContext = McpTransportContext.EMPTY; - } - /** * Create a new asynchronous exchange with the client. * @param session The server session representing a 1-1 interaction. @@ -142,10 +120,11 @@ public String sessionId() { */ public Mono createMessage(McpSchema.CreateMessageRequest createMessageRequest) { if (this.clientCapabilities == null) { - return Mono.error(new McpError("Client must be initialized. Call the initialize method first!")); + return Mono + .error(new IllegalStateException("Client must be initialized. Call the initialize method first!")); } if (this.clientCapabilities.sampling() == null) { - return Mono.error(new McpError("Client must be configured with sampling capabilities")); + return Mono.error(new IllegalStateException("Client must be configured with sampling capabilities")); } return this.session.sendRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, createMessageRequest, CREATE_MESSAGE_RESULT_TYPE_REF); @@ -167,10 +146,11 @@ public Mono createMessage(McpSchema.CreateMessage */ public Mono createElicitation(McpSchema.ElicitRequest elicitRequest) { if (this.clientCapabilities == null) { - return Mono.error(new McpError("Client must be initialized. Call the initialize method first!")); + return Mono + .error(new IllegalStateException("Client must be initialized. Call the initialize method first!")); } if (this.clientCapabilities.elicitation() == null) { - return Mono.error(new McpError("Client must be configured with elicitation capabilities")); + return Mono.error(new IllegalStateException("Client must be configured with elicitation capabilities")); } return this.session.sendRequest(McpSchema.METHOD_ELICITATION_CREATE, elicitRequest, ELICITATION_RESULT_TYPE_REF); @@ -215,7 +195,7 @@ public Mono listRoots(String cursor) { public Mono loggingNotification(LoggingMessageNotification loggingMessageNotification) { if (loggingMessageNotification == null) { - return Mono.error(new McpError("Logging message must not be null")); + return Mono.error(new IllegalStateException("Logging message must not be null")); } return Mono.defer(() -> { @@ -234,7 +214,7 @@ public Mono loggingNotification(LoggingMessageNotification loggingMessageN */ public Mono progressNotification(McpSchema.ProgressNotification progressNotification) { if (progressNotification == null) { - return Mono.error(new McpError("Progress notification must not be null")); + return Mono.error(new IllegalStateException("Progress notification must not be null")); } return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_PROGRESS, progressNotification); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index 7fe9ef2a2..360eb607d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -66,9 +66,9 @@ * Example of creating a basic synchronous server:
{@code
  * McpServer.sync(transportProvider)
  *     .serverInfo("my-server", "1.0.0")
- *     .tool(Tool.builder().name("calculator").title("Performs calculations").inputSchema(schema).build(),
- *           (exchange, args) -> CallToolResult.builder()
- *                   .content(List.of(new McpSchema.TextContent("Result: " + calculate(args))))
+ *     .toolCall(Tool.builder().name("calculator").title("Performs calculations").inputSchema(schema).build(),
+ *           (exchange, request) -> CallToolResult.builder()
+ *                   .content(List.of(new McpSchema.TextContent("Result: " + calculate(request.arguments()))))
  *                   .isError(false)
  *                   .build())
  *     .build();
@@ -77,8 +77,8 @@
  * Example of creating a basic asynchronous server: 
{@code
  * McpServer.async(transportProvider)
  *     .serverInfo("my-server", "1.0.0")
- *     .tool(Tool.builder().name("calculator").title("Performs calculations").inputSchema(schema).build(),
- *           (exchange, args) -> Mono.fromSupplier(() -> calculate(args))
+ *     .toolCall(Tool.builder().name("calculator").title("Performs calculations").inputSchema(schema).build(),
+ *           (exchange, request) -> Mono.fromSupplier(() -> calculate(request.arguments()))
  *               .map(result -> CallToolResult.builder()
  *                   .content(List.of(new McpSchema.TextContent("Result: " + result)))
  *                   .isError(false)
@@ -441,46 +441,6 @@ public AsyncSpecification capabilities(McpSchema.ServerCapabilities serverCap
 			return this;
 		}
 
-		/**
-		 * Adds a single tool with its implementation handler to the server. This is a
-		 * convenience method for registering individual tools without creating a
-		 * {@link McpServerFeatures.AsyncToolSpecification} explicitly.
-		 *
-		 * 

- * Example usage:

{@code
-		 * .tool(
-		 *     Tool.builder().name("calculator").title("Performs calculations").inputSchema(schema).build(),
-		 *     (exchange, args) -> Mono.fromSupplier(() -> calculate(args))
-		 *         .map(result -> CallToolResult.builder()
-		 *                   .content(List.of(new McpSchema.TextContent("Result: " + result)))
-		 *                   .isError(false)
-		 *                   .build()))
-		 * )
-		 * }
- * @param tool The tool definition including name, description, and schema. Must - * not be null. - * @param handler The function that implements the tool's logic. Must not be null. - * The function's first argument is an {@link McpAsyncServerExchange} upon which - * the server can interact with the connected client. The second argument is the - * map of arguments passed to the tool. - * @return This builder instance for method chaining - * @throws IllegalArgumentException if tool or handler is null - * @deprecated Use {@link #toolCall(McpSchema.Tool, BiFunction)} instead for tool - * calls that require a request object. - */ - @Deprecated - public AsyncSpecification tool(McpSchema.Tool tool, - BiFunction, Mono> handler) { - Assert.notNull(tool, "Tool must not be null"); - Assert.notNull(handler, "Handler must not be null"); - validateToolName(tool.name()); - assertNoDuplicateTool(tool.name()); - - this.tools.add(new McpServerFeatures.AsyncToolSpecification(tool, handler)); - - return this; - } - /** * Adds a single tool with its implementation handler to the server. This is a * convenience method for registering individual tools without creating a @@ -1064,45 +1024,6 @@ public SyncSpecification capabilities(McpSchema.ServerCapabilities serverCapa return this; } - /** - * Adds a single tool with its implementation handler to the server. This is a - * convenience method for registering individual tools without creating a - * {@link McpServerFeatures.SyncToolSpecification} explicitly. - * - *

- * Example usage:

{@code
-		 * .tool(
-		 *     Tool.builder().name("calculator").title("Performs calculations".inputSchema(schema).build(),
-		 *     (exchange, args) -> CallToolResult.builder()
-		 *                   .content(List.of(new McpSchema.TextContent("Result: " + calculate(args))))
-		 *                   .isError(false)
-		 *                   .build())
-		 * )
-		 * }
- * @param tool The tool definition including name, description, and schema. Must - * not be null. - * @param handler The function that implements the tool's logic. Must not be null. - * The function's first argument is an {@link McpSyncServerExchange} upon which - * the server can interact with the connected client. The second argument is the - * list of arguments passed to the tool. - * @return This builder instance for method chaining - * @throws IllegalArgumentException if tool or handler is null - * @deprecated Use {@link #toolCall(McpSchema.Tool, BiFunction)} instead for tool - * calls that require a request object. - */ - @Deprecated - public SyncSpecification tool(McpSchema.Tool tool, - BiFunction, McpSchema.CallToolResult> handler) { - Assert.notNull(tool, "Tool must not be null"); - Assert.notNull(handler, "Handler must not be null"); - validateToolName(tool.name()); - assertNoDuplicateTool(tool.name()); - - this.tools.add(new McpServerFeatures.SyncToolSpecification(tool, handler)); - - return this; - } - /** * Adds a single tool with its implementation handler to the server. This is a * convenience method for registering individual tools without creating a @@ -1123,7 +1044,7 @@ public SyncSpecification toolCall(McpSchema.Tool tool, validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); - this.tools.add(new McpServerFeatures.SyncToolSpecification(tool, null, handler)); + this.tools.add(new McpServerFeatures.SyncToolSpecification(tool, handler)); return this; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java index fe0608b1c..a0cbae0f2 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java @@ -223,19 +223,8 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se * map of tool arguments. */ public record AsyncToolSpecification(McpSchema.Tool tool, - @Deprecated BiFunction, Mono> call, BiFunction> callHandler) { - /** - * @deprecated Use {@link AsyncToolSpecification(McpSchema.Tool, null, - * BiFunction)} instead. - **/ - @Deprecated - public AsyncToolSpecification(McpSchema.Tool tool, - BiFunction, Mono> call) { - this(tool, call, (exchange, toolReq) -> call.apply(exchange, toolReq.arguments())); - } - static AsyncToolSpecification fromSync(SyncToolSpecification syncToolSpec) { return fromSync(syncToolSpec, false); } @@ -247,13 +236,6 @@ static AsyncToolSpecification fromSync(SyncToolSpecification syncToolSpec, boole return null; } - BiFunction, Mono> deprecatedCall = (syncToolSpec - .call() != null) ? (exchange, map) -> { - var toolResult = Mono - .fromCallable(() -> syncToolSpec.call().apply(new McpSyncServerExchange(exchange), map)); - return immediate ? toolResult : toolResult.subscribeOn(Schedulers.boundedElastic()); - } : null; - BiFunction> callHandler = ( exchange, req) -> { var toolResult = Mono @@ -261,7 +243,7 @@ static AsyncToolSpecification fromSync(SyncToolSpecification syncToolSpec, boole return immediate ? toolResult : toolResult.subscribeOn(Schedulers.boundedElastic()); }; - return new AsyncToolSpecification(syncToolSpec.tool(), deprecatedCall, callHandler); + return new AsyncToolSpecification(syncToolSpec.tool(), callHandler); } /** @@ -304,7 +286,7 @@ public AsyncToolSpecification build() { Assert.notNull(tool, "Tool must not be null"); Assert.notNull(callHandler, "Call handler function must not be null"); - return new AsyncToolSpecification(tool, null, callHandler); + return new AsyncToolSpecification(tool, callHandler); } } @@ -523,26 +505,16 @@ static AsyncCompletionSpecification fromSync(SyncCompletionSpecification complet * }
* * @param tool The tool definition including name, description, and parameter schema - * @param call (Deprected) The function that implements the tool's logic, receiving - * arguments and returning results. The function's first argument is an - * {@link McpSyncServerExchange} upon which the server can interact with the connected * @param callHandler The function that implements the tool's logic, receiving a * {@link McpSyncServerExchange} and a * {@link io.modelcontextprotocol.spec.McpSchema.CallToolRequest} and returning * results. The function's first argument is an {@link McpSyncServerExchange} upon - * which the server can interact with the client. The second arguments is a map of - * arguments passed to the tool. + * which the server can interact with the client. The second argument is a request + * object containing the arguments passed to the tool. */ public record SyncToolSpecification(McpSchema.Tool tool, - @Deprecated BiFunction, McpSchema.CallToolResult> call, BiFunction callHandler) { - @Deprecated - public SyncToolSpecification(McpSchema.Tool tool, - BiFunction, McpSchema.CallToolResult> call) { - this(tool, call, (exchange, toolReq) -> call.apply(exchange, toolReq.arguments())); - } - /** * Builder for creating SyncToolSpecification instances. */ @@ -583,7 +555,7 @@ public SyncToolSpecification build() { Assert.notNull(tool, "Tool must not be null"); Assert.notNull(callHandler, "CallTool function must not be null"); - return new SyncToolSpecification(tool, null, callHandler); + return new SyncToolSpecification(tool, callHandler); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java index 10f0e5a31..d33299d02 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java @@ -230,21 +230,6 @@ public void notifyPromptsListChanged() { this.asyncServer.notifyPromptsListChanged().block(); } - /** - * This implementation would, incorrectly, broadcast the logging message to all - * connected clients, using a single minLoggingLevel for all of them. Similar to the - * sampling and roots, the logging level should be set per client session and use the - * ServerExchange to send the logging message to the right client. - * @param loggingMessageNotification The logging message to send - * @deprecated Use - * {@link McpSyncServerExchange#loggingNotification(LoggingMessageNotification)} - * instead. - */ - @Deprecated - public void loggingNotification(LoggingMessageNotification loggingMessageNotification) { - this.asyncServer.loggingNotification(loggingMessageNotification).block(); - } - /** * Close the server gracefully. */ diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java index d84518778..7037ff293 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java @@ -343,7 +343,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) response.setContentType(APPLICATION_JSON); response.setCharacterEncoding(UTF_8); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - String jsonError = jsonMapper.writeValueAsString(new McpError("Session ID missing in message endpoint")); + String jsonError = jsonMapper.writeValueAsString(McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) + .message("Session ID missing in message endpoint") + .build()); PrintWriter writer = response.getWriter(); writer.write(jsonError); writer.flush(); @@ -356,7 +358,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) response.setContentType(APPLICATION_JSON); response.setCharacterEncoding(UTF_8); response.setStatus(HttpServletResponse.SC_NOT_FOUND); - String jsonError = jsonMapper.writeValueAsString(new McpError("Session not found: " + sessionId)); + String jsonError = jsonMapper.writeValueAsString(McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Session not found: " + sessionId) + .build()); PrintWriter writer = response.getWriter(); writer.write(jsonError); writer.flush(); @@ -383,7 +387,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) catch (Exception e) { logger.error("Error processing message: {}", e.getMessage()); try { - McpError mcpError = new McpError(e.getMessage()); + McpError mcpError = McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message(e.getMessage()) + .build(); response.setContentType(APPLICATION_JSON); response.setCharacterEncoding(UTF_8); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java index af25df28e..047aeebe8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java @@ -147,7 +147,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) String accept = request.getHeader(ACCEPT); if (accept == null || !(accept.contains(APPLICATION_JSON) && accept.contains(TEXT_EVENT_STREAM))) { this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, - new McpError("Both application/json and text/event-stream required in Accept header")); + McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) + .message("Both application/json and text/event-stream required in Accept header") + .build()); return; } @@ -180,7 +182,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) catch (Exception e) { logger.error("Failed to handle request: {}", e.getMessage()); this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError("Failed to handle request: " + e.getMessage())); + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Failed to handle request: " + e.getMessage()) + .build()); } } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { @@ -193,22 +197,29 @@ else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { catch (Exception e) { logger.error("Failed to handle notification: {}", e.getMessage()); this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError("Failed to handle notification: " + e.getMessage())); + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Failed to handle notification: " + e.getMessage()) + .build()); } } else { this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, - new McpError("The server accepts either requests or notifications")); + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("The server accepts either requests or notifications") + .build()); } } catch (IllegalArgumentException | IOException e) { logger.error("Failed to deserialize message: {}", e.getMessage()); - this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, new McpError("Invalid message format")); + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST).message("Invalid message format").build()); } catch (Exception e) { logger.error("Unexpected error handling message: {}", e.getMessage()); this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError("Unexpected error: " + e.getMessage())); + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Unexpected error: " + e.getMessage()) + .build()); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index ccd1d1ccb..d7561188c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java @@ -282,7 +282,8 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) if (!badRequestErrors.isEmpty()) { String combinedMessage = String.join("; ", badRequestErrors); - this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, new McpError(combinedMessage)); + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND).message(combinedMessage).build()); return; } @@ -430,7 +431,8 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) && jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) { if (!badRequestErrors.isEmpty()) { String combinedMessage = String.join("; ", badRequestErrors); - this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, new McpError(combinedMessage)); + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND).message(combinedMessage).build()); return; } @@ -460,7 +462,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) catch (Exception e) { logger.error("Failed to initialize session: {}", e.getMessage()); this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError("Failed to initialize session: " + e.getMessage())); + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Failed to initialize session: " + e.getMessage()) + .build()); return; } } @@ -473,7 +477,8 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) if (!badRequestErrors.isEmpty()) { String combinedMessage = String.join("; ", badRequestErrors); - this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, new McpError(combinedMessage)); + this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, + McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND).message(combinedMessage).build()); return; } @@ -481,7 +486,9 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) if (session == null) { this.responseError(response, HttpServletResponse.SC_NOT_FOUND, - new McpError("Session not found: " + sessionId)); + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Session not found: " + sessionId) + .build()); return; } @@ -523,19 +530,23 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { } else { this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError("Unknown message type")); + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST).message("Unknown message type").build()); } } catch (IllegalArgumentException | IOException e) { logger.error("Failed to deserialize message: {}", e.getMessage()); this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, - new McpError("Invalid message format: " + e.getMessage())); + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Invalid message format: " + e.getMessage()) + .build()); } catch (Exception e) { logger.error("Error handling message: {}", e.getMessage()); try { this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError("Error processing message: " + e.getMessage())); + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message("Error processing message: " + e.getMessage()) + .build()); } catch (IOException ex) { logger.error(FAILED_TO_SEND_ERROR_RESPONSE, ex.getMessage()); @@ -584,7 +595,9 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response if (request.getHeader(HttpHeaders.MCP_SESSION_ID) == null) { this.responseError(response, HttpServletResponse.SC_BAD_REQUEST, - new McpError("Session ID required in mcp-session-id header")); + McpError.builder(McpSchema.ErrorCodes.METHOD_NOT_FOUND) + .message("Session ID required in mcp-session-id header") + .build()); return; } @@ -605,7 +618,7 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); try { this.responseError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - new McpError(e.getMessage())); + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); } catch (IOException ex) { logger.error(FAILED_TO_SEND_ERROR_RESPONSE, ex.getMessage()); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java index 68be62931..d288ea3d6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java @@ -98,7 +98,7 @@ public void setSessionFactory(McpServerSession.Factory sessionFactory) { @Override public Mono notifyClients(String method, Object params) { if (this.session == null) { - return Mono.error(new McpError("No session to close")); + return Mono.error(new IllegalStateException("No session to close")); } return this.session.sendNotification(method, params) .doOnError(e -> logger.error("Failed to send notification: {}", e.getMessage())); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java index 0ba7ab3b8..80b5ae246 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpClientSession.java @@ -96,21 +96,6 @@ public interface NotificationHandler { } - /** - * Creates a new McpClientSession with the specified configuration and handlers. - * @param requestTimeout Duration to wait for responses - * @param transport Transport implementation for message exchange - * @param requestHandlers Map of method names to request handlers - * @param notificationHandlers Map of method names to notification handlers - * @deprecated Use - * {@link #McpClientSession(Duration, McpClientTransport, Map, Map, Function)} - */ - @Deprecated - public McpClientSession(Duration requestTimeout, McpClientTransport transport, - Map> requestHandlers, Map notificationHandlers) { - this(requestTimeout, transport, requestHandlers, notificationHandlers, Function.identity()); - } - /** * Creates a new McpClientSession with the specified configuration and handlers. * @param requestTimeout Duration to wait for responses diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpError.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpError.java index d6e549fdc..a3e7890e6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpError.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpError.java @@ -27,11 +27,6 @@ public McpError(JSONRPCError jsonRpcError) { this.jsonRpcError = jsonRpcError; } - @Deprecated - public McpError(Object error) { - super(error.toString()); - } - public JSONRPCError getJsonRpcError() { return jsonRpcError; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 97bde0b10..bb9cead7e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -41,9 +41,6 @@ public final class McpSchema { private McpSchema() { } - @Deprecated - public static final String LATEST_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_11_25; - public static final String JSONRPC_VERSION = "2.0"; public static final String FIRST_PAGE = null; @@ -798,35 +795,6 @@ public record Resource( // @formatter:off @JsonProperty("annotations") Annotations annotations, @JsonProperty("_meta") Map meta) implements ResourceContent { // @formatter:on - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link Resource#builder()} instead. - */ - @Deprecated - public Resource(String uri, String name, String title, String description, String mimeType, Long size, - Annotations annotations) { - this(uri, name, title, description, mimeType, size, annotations, null); - } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link Resource#builder()} instead. - */ - @Deprecated - public Resource(String uri, String name, String description, String mimeType, Long size, - Annotations annotations) { - this(uri, name, null, description, mimeType, size, annotations, null); - } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link Resource#builder()} instead. - */ - @Deprecated - public Resource(String uri, String name, String description, String mimeType, Annotations annotations) { - this(uri, name, null, description, mimeType, null, annotations, null); - } - public static Builder builder() { return new Builder(); } @@ -1592,36 +1560,6 @@ public record CallToolResult( // @formatter:off @JsonProperty("structuredContent") Object structuredContent, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on - /** - * @deprecated use the builder instead. - */ - @Deprecated - public CallToolResult(List content, Boolean isError) { - this(content, isError, (Object) null, null); - } - - /** - * @deprecated use the builder instead. - */ - @Deprecated - public CallToolResult(List content, Boolean isError, Map structuredContent) { - this(content, isError, structuredContent, null); - } - - /** - * Creates a new instance of {@link CallToolResult} with a string containing the - * tool result. - * @param content The content of the tool result. This will be mapped to a - * one-sized list with a {@link TextContent} element. - * @param isError If true, indicates that the tool execution failed and the - * content contains error information. If false or absent, indicates successful - * execution. - */ - @Deprecated - public CallToolResult(String content, Boolean isError) { - this(List.of(new TextContent(content)), isError, null); - } - /** * Creates a builder for {@link CallToolResult}. * @return a new builder instance @@ -2619,33 +2557,6 @@ public TextContent(Annotations annotations, String text) { public TextContent(String content) { this(null, content, null); } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link TextContent#TextContent(Annotations, String)} instead. - */ - @Deprecated - public TextContent(List audience, Double priority, String content) { - this(audience != null || priority != null ? new Annotations(audience, priority) : null, content, null); - } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link TextContent#annotations()} instead. - */ - @Deprecated - public List audience() { - return annotations == null ? null : annotations.audience(); - } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link TextContent#annotations()} instead. - */ - @Deprecated - public Double priority() { - return annotations == null ? null : annotations.priority(); - } } /** @@ -2668,34 +2579,6 @@ public record ImageContent( // @formatter:off public ImageContent(Annotations annotations, String data, String mimeType) { this(annotations, data, mimeType, null); } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link ImageContent#ImageContent(Annotations, String, String)} instead. - */ - @Deprecated - public ImageContent(List audience, Double priority, String data, String mimeType) { - this(audience != null || priority != null ? new Annotations(audience, priority) : null, data, mimeType, - null); - } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link ImageContent#annotations()} instead. - */ - @Deprecated - public List audience() { - return annotations == null ? null : annotations.audience(); - } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link ImageContent#annotations()} instead. - */ - @Deprecated - public Double priority() { - return annotations == null ? null : annotations.priority(); - } } /** @@ -2742,34 +2625,6 @@ public record EmbeddedResource( // @formatter:off public EmbeddedResource(Annotations annotations, ResourceContents resource) { this(annotations, resource, null); } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link EmbeddedResource#EmbeddedResource(Annotations, ResourceContents)} - * instead. - */ - @Deprecated - public EmbeddedResource(List audience, Double priority, ResourceContents resource) { - this(audience != null || priority != null ? new Annotations(audience, priority) : null, resource, null); - } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link EmbeddedResource#annotations()} instead. - */ - @Deprecated - public List audience() { - return annotations == null ? null : annotations.audience(); - } - - /** - * @deprecated Only exists for backwards-compatibility purposes. Use - * {@link EmbeddedResource#annotations()} instead. - */ - @Deprecated - public Double priority() { - return annotations == null ? null : annotations.priority(); - } } /** diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java index 241f7d8b5..ecb1dafd8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java @@ -86,34 +86,6 @@ public McpServerSession(String id, Duration requestTimeout, McpServerTransport t this.notificationHandlers = notificationHandlers; } - /** - * Creates a new server session with the given parameters and the transport to use. - * @param id session id - * @param transport the transport to use - * @param initHandler called when a - * {@link io.modelcontextprotocol.spec.McpSchema.InitializeRequest} is received by the - * server - * @param initNotificationHandler called when a - * {@link io.modelcontextprotocol.spec.McpSchema#METHOD_NOTIFICATION_INITIALIZED} is - * received. - * @param requestHandlers map of request handlers to use - * @param notificationHandlers map of notification handlers to use - * @deprecated Use - * {@link #McpServerSession(String, Duration, McpServerTransport, McpInitRequestHandler, Map, Map)} - */ - @Deprecated - public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport, - McpInitRequestHandler initHandler, InitNotificationHandler initNotificationHandler, - Map> requestHandlers, - Map notificationHandlers) { - this.id = id; - this.requestTimeout = requestTimeout; - this.transport = transport; - this.initRequestHandler = initHandler; - this.requestHandlers = requestHandlers; - this.notificationHandlers = notificationHandlers; - } - /** * Retrieve the session id. * @return session id @@ -355,23 +327,6 @@ public void close() { this.transport.close(); } - /** - * Request handler for the initialization request. - * - * @deprecated Use {@link McpInitRequestHandler} - */ - @Deprecated - public interface InitRequestHandler { - - /** - * Handles the initialization request. - * @param initializeRequest the initialization request by the client - * @return a Mono that will emit the result of the initialization - */ - Mono handle(McpSchema.InitializeRequest initializeRequest); - - } - /** * Notification handler for the initialization notification from the client. */ @@ -385,46 +340,6 @@ public interface InitNotificationHandler { } - /** - * A handler for client-initiated notifications. - * - * @deprecated Use {@link McpNotificationHandler} - */ - @Deprecated - public interface NotificationHandler { - - /** - * Handles a notification from the client. - * @param exchange the exchange associated with the client that allows calling - * back to the connected client or inspecting its capabilities. - * @param params the parameters of the notification. - * @return a Mono that completes once the notification is handled. - */ - Mono handle(McpAsyncServerExchange exchange, Object params); - - } - - /** - * A handler for client-initiated requests. - * - * @param the type of the response that is expected as a result of handling the - * request. - * @deprecated Use {@link McpRequestHandler} - */ - @Deprecated - public interface RequestHandler { - - /** - * Handles a request from the client. - * @param exchange the exchange associated with the client that allows calling - * back to the connected client or inspecting its capabilities. - * @param params the parameters of the request. - * @return a Mono that will emit the response to the request. - */ - Mono handle(McpAsyncServerExchange exchange, Object params); - - } - /** * Factory for creating server sessions which delegate to a provided 1:1 transport * with a connected client. diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java b/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java index f9fc41b7a..061a95e69 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java @@ -9,10 +9,10 @@ import java.util.function.BiConsumer; import java.util.function.Function; -import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; import reactor.core.publisher.Mono; @@ -29,7 +29,7 @@ public class MockMcpClientTransport implements McpClientTransport { private final BiConsumer interceptor; - private String protocolVersion = McpSchema.LATEST_PROTOCOL_VERSION; + private String protocolVersion = ProtocolVersions.MCP_2025_11_25; public MockMcpClientTransport() { this((t, msg) -> { diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java index c16b06a45..897ae2ccc 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java @@ -56,7 +56,6 @@ void builderShouldCreateValidAsyncToolSpecification() { assertThat(specification).isNotNull(); assertThat(specification.tool()).isEqualTo(tool); assertThat(specification.callHandler()).isNotNull(); - assertThat(specification.call()).isNull(); // deprecated field should be null } @Test @@ -107,12 +106,8 @@ void builtSpecificationShouldExecuteCallToolCorrectly() { McpServerFeatures.AsyncToolSpecification specification = McpServerFeatures.AsyncToolSpecification.builder() .tool(tool) - .callHandler((exchange, request) -> { - return Mono.just(CallToolResult.builder() - .content(List.of(new TextContent(expectedResult))) - .isError(false) - .build()); - }) + .callHandler((exchange, request) -> Mono.just( + CallToolResult.builder().content(List.of(new TextContent(expectedResult))).isError(false).build())) .build(); CallToolRequest request = new CallToolRequest("calculator", Map.of()); @@ -127,54 +122,6 @@ void builtSpecificationShouldExecuteCallToolCorrectly() { }).verifyComplete(); } - @Test - @SuppressWarnings("deprecation") - void deprecatedConstructorShouldWorkCorrectly() { - Tool tool = McpSchema.Tool.builder() - .name("deprecated-tool") - .title("A deprecated tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - String expectedResult = "deprecated result"; - - // Test the deprecated constructor that takes a 'call' function - McpServerFeatures.AsyncToolSpecification specification = new McpServerFeatures.AsyncToolSpecification(tool, - (exchange, - arguments) -> Mono.just(CallToolResult.builder() - .content(List.of(new TextContent(expectedResult))) - .isError(false) - .build())); - - assertThat(specification).isNotNull(); - assertThat(specification.tool()).isEqualTo(tool); - assertThat(specification.call()).isNotNull(); // deprecated field should be set - assertThat(specification.callHandler()).isNotNull(); // should be automatically - // created - - // Test that the callTool function works (it should delegate to the call function) - CallToolRequest request = new CallToolRequest("deprecated-tool", Map.of("arg1", "value1")); - Mono resultMono = specification.callHandler().apply(null, request); - - StepVerifier.create(resultMono).assertNext(result -> { - assertThat(result).isNotNull(); - assertThat(result.content()).hasSize(1); - assertThat(result.content().get(0)).isInstanceOf(TextContent.class); - assertThat(((TextContent) result.content().get(0)).text()).isEqualTo(expectedResult); - assertThat(result.isError()).isFalse(); - }).verifyComplete(); - - // Test that the deprecated call function also works directly - Mono callResultMono = specification.call().apply(null, request.arguments()); - - StepVerifier.create(callResultMono).assertNext(result -> { - assertThat(result).isNotNull(); - assertThat(result.content()).hasSize(1); - assertThat(result.content().get(0)).isInstanceOf(TextContent.class); - assertThat(((TextContent) result.content().get(0)).text()).isEqualTo(expectedResult); - assertThat(result.isError()).isFalse(); - }).verifyComplete(); - } - @Test void fromSyncShouldConvertSyncToolSpecificationCorrectly() { Tool tool = McpSchema.Tool.builder() @@ -200,8 +147,6 @@ void fromSyncShouldConvertSyncToolSpecificationCorrectly() { assertThat(asyncSpec).isNotNull(); assertThat(asyncSpec.tool()).isEqualTo(tool); assertThat(asyncSpec.callHandler()).isNotNull(); - assertThat(asyncSpec.call()).isNull(); // should be null since sync spec doesn't - // have deprecated call // Test that the converted async specification works correctly CallToolRequest request = new CallToolRequest("sync-tool", Map.of("param", "value")); @@ -216,59 +161,6 @@ void fromSyncShouldConvertSyncToolSpecificationCorrectly() { }).verifyComplete(); } - @Test - @SuppressWarnings("deprecation") - void fromSyncShouldConvertSyncToolSpecificationWithDeprecatedCallCorrectly() { - Tool tool = McpSchema.Tool.builder() - .name("sync-deprecated-tool") - .title("A sync tool with deprecated call") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - String expectedResult = "sync deprecated result"; - McpAsyncServerExchange nullExchange = null; // Mock or create a suitable exchange - // if needed - - // Create a sync tool specification using the deprecated constructor - McpServerFeatures.SyncToolSpecification syncSpec = new McpServerFeatures.SyncToolSpecification(tool, - (exchange, arguments) -> CallToolResult.builder() - .content(List.of(new TextContent(expectedResult))) - .isError(false) - .build()); - - // Convert to async using fromSync - McpServerFeatures.AsyncToolSpecification asyncSpec = McpServerFeatures.AsyncToolSpecification - .fromSync(syncSpec); - - assertThat(asyncSpec).isNotNull(); - assertThat(asyncSpec.tool()).isEqualTo(tool); - assertThat(asyncSpec.callHandler()).isNotNull(); - assertThat(asyncSpec.call()).isNotNull(); // should be set since sync spec has - // deprecated call - - // Test that the converted async specification works correctly via callTool - CallToolRequest request = new CallToolRequest("sync-deprecated-tool", Map.of("param", "value")); - Mono resultMono = asyncSpec.callHandler().apply(nullExchange, request); - - StepVerifier.create(resultMono).assertNext(result -> { - assertThat(result).isNotNull(); - assertThat(result.content()).hasSize(1); - assertThat(result.content().get(0)).isInstanceOf(TextContent.class); - assertThat(((TextContent) result.content().get(0)).text()).isEqualTo(expectedResult); - assertThat(result.isError()).isFalse(); - }).verifyComplete(); - - // Test that the deprecated call function also works - Mono callResultMono = asyncSpec.call().apply(nullExchange, request.arguments()); - - StepVerifier.create(callResultMono).assertNext(result -> { - assertThat(result).isNotNull(); - assertThat(result.content()).hasSize(1); - assertThat(result.content().get(0)).isInstanceOf(TextContent.class); - assertThat(((TextContent) result.content().get(0)).text()).isEqualTo(expectedResult); - assertThat(result.isError()).isFalse(); - }).verifyComplete(); - } - @Test void fromSyncShouldReturnNullWhenSyncSpecIsNull() { assertThat(McpServerFeatures.AsyncToolSpecification.fromSync(null)).isNull(); @@ -302,7 +194,8 @@ void tearDown() { void defaultShouldThrowOnInvalidName() { Tool invalidTool = Tool.builder().name("invalid tool name").build(); - assertThatThrownBy(() -> McpServer.async(transportProvider).tool(invalidTool, (exchange, args) -> null)) + assertThatThrownBy( + () -> McpServer.async(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("invalid characters"); } @@ -312,7 +205,7 @@ void lenientDefaultShouldLogOnInvalidName() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); Tool invalidTool = Tool.builder().name("invalid tool name").build(); - assertThatCode(() -> McpServer.async(transportProvider).tool(invalidTool, (exchange, args) -> null)) + assertThatCode(() -> McpServer.async(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) .doesNotThrowAnyException(); assertThat(logAppender.list).hasSize(1); } @@ -323,7 +216,7 @@ void lenientConfigurationShouldLogOnInvalidName() { assertThatCode(() -> McpServer.async(transportProvider) .strictToolNameValidation(false) - .tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException(); + .toolCall(invalidTool, (exchange, request) -> null)).doesNotThrowAnyException(); assertThat(logAppender.list).hasSize(1); } @@ -334,7 +227,7 @@ void serverConfigurationShouldOverrideDefault() { assertThatThrownBy(() -> McpServer.async(transportProvider) .strictToolNameValidation(true) - .tool(invalidTool, (exchange, args) -> null)).isInstanceOf(IllegalArgumentException.class) + .toolCall(invalidTool, (exchange, request) -> null)).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("invalid characters"); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java index 640d34c9c..e6161a59f 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java @@ -215,7 +215,7 @@ void testGetClientInfo() { @Test void testLoggingNotificationWithNullMessage() { StepVerifier.create(exchange.loggingNotification(null)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class).hasMessage("Logging message must not be null"); + assertThat(error).isInstanceOf(IllegalStateException.class).hasMessage("Logging message must not be null"); }); } @@ -301,7 +301,8 @@ void testLoggingNotificationWithSessionError() { @Test void testCreateElicitationWithNullCapabilities() { // Given - Create exchange with null capabilities - McpAsyncServerExchange exchangeWithNullCapabilities = new McpAsyncServerExchange(mockSession, null, clientInfo); + McpAsyncServerExchange exchangeWithNullCapabilities = new McpAsyncServerExchange("testSessionId", mockSession, + null, clientInfo, McpTransportContext.EMPTY); McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your name") @@ -309,7 +310,7 @@ void testCreateElicitationWithNullCapabilities() { StepVerifier.create(exchangeWithNullCapabilities.createElicitation(elicitRequest)) .verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class) + assertThat(error).isInstanceOf(IllegalStateException.class) .hasMessage("Client must be initialized. Call the initialize method first!"); }); @@ -324,15 +325,15 @@ void testCreateElicitationWithoutElicitationCapabilities() { .roots(true) .build(); - McpAsyncServerExchange exchangeWithoutElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithoutElicitation, clientInfo); + McpAsyncServerExchange exchangeWithoutElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithoutElicitation, clientInfo, McpTransportContext.EMPTY); McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your name") .build(); StepVerifier.create(exchangeWithoutElicitation.createElicitation(elicitRequest)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class) + assertThat(error).isInstanceOf(IllegalStateException.class) .hasMessage("Client must be configured with elicitation capabilities"); }); @@ -348,8 +349,8 @@ void testCreateElicitationWithComplexRequest() { .elicitation() .build(); - McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithElicitation, clientInfo); + McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); // Create a complex elicit request with schema java.util.Map requestedSchema = new java.util.HashMap<>(); @@ -391,8 +392,8 @@ void testCreateElicitationWithDeclineAction() { .elicitation() .build(); - McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithElicitation, clientInfo); + McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide sensitive information") @@ -418,8 +419,8 @@ void testCreateElicitationWithCancelAction() { .elicitation() .build(); - McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithElicitation, clientInfo); + McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your information") @@ -445,8 +446,8 @@ void testCreateElicitationWithSessionError() { .elicitation() .build(); - McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithElicitation, clientInfo); + McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your name") @@ -467,7 +468,8 @@ void testCreateElicitationWithSessionError() { @Test void testCreateMessageWithNullCapabilities() { - McpAsyncServerExchange exchangeWithNullCapabilities = new McpAsyncServerExchange(mockSession, null, clientInfo); + McpAsyncServerExchange exchangeWithNullCapabilities = new McpAsyncServerExchange("testSessionId", mockSession, + null, clientInfo, McpTransportContext.EMPTY); McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays @@ -476,7 +478,7 @@ void testCreateMessageWithNullCapabilities() { StepVerifier.create(exchangeWithNullCapabilities.createMessage(createMessageRequest)) .verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class) + assertThat(error).isInstanceOf(IllegalStateException.class) .hasMessage("Client must be initialized. Call the initialize method first!"); }); @@ -492,8 +494,8 @@ void testCreateMessageWithoutSamplingCapabilities() { .roots(true) .build(); - McpAsyncServerExchange exchangeWithoutSampling = new McpAsyncServerExchange(mockSession, - capabilitiesWithoutSampling, clientInfo); + McpAsyncServerExchange exchangeWithoutSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithoutSampling, clientInfo, McpTransportContext.EMPTY); McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays @@ -501,7 +503,7 @@ void testCreateMessageWithoutSamplingCapabilities() { .build(); StepVerifier.create(exchangeWithoutSampling.createMessage(createMessageRequest)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class) + assertThat(error).isInstanceOf(IllegalStateException.class) .hasMessage("Client must be configured with sampling capabilities"); }); @@ -517,8 +519,8 @@ void testCreateMessageWithBasicRequest() { .sampling() .build(); - McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange(mockSession, capabilitiesWithSampling, - clientInfo); + McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays @@ -553,8 +555,8 @@ void testCreateMessageWithImageContent() { .sampling() .build(); - McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange(mockSession, capabilitiesWithSampling, - clientInfo); + McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); // Create request with image content McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() @@ -588,8 +590,8 @@ void testCreateMessageWithSessionError() { .sampling() .build(); - McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange(mockSession, capabilitiesWithSampling, - clientInfo); + McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays @@ -612,8 +614,8 @@ void testCreateMessageWithIncludeContext() { .sampling() .build(); - McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange(mockSession, capabilitiesWithSampling, - clientInfo); + McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, @@ -662,7 +664,7 @@ void testPingWithSuccessfulResponse() { @Test void testPingWithMcpError() { // Given - Mock an MCP-specific error during ping - McpError mcpError = new McpError("Server unavailable"); + McpError mcpError = McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Server unavailable").build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_PING), eq(null), any(TypeRef.class))) .thenReturn(Mono.error(mcpError)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java index 069d0f896..fba733c9a 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerSession; @@ -54,7 +55,8 @@ void setUp() { clientInfo = new McpSchema.Implementation("test-client", "1.0.0"); - asyncExchange = new McpAsyncServerExchange(mockSession, clientCapabilities, clientInfo); + asyncExchange = new McpAsyncServerExchange("testSessionId", mockSession, clientCapabilities, clientInfo, + McpTransportContext.EMPTY); exchange = new McpSyncServerExchange(asyncExchange); } @@ -212,7 +214,7 @@ void testGetClientInfo() { @Test void testLoggingNotificationWithNullMessage() { - assertThatThrownBy(() -> exchange.loggingNotification(null)).isInstanceOf(McpError.class) + assertThatThrownBy(() -> exchange.loggingNotification(null)).isInstanceOf(IllegalStateException.class) .hasMessage("Logging message must not be null"); } @@ -294,8 +296,8 @@ void testLoggingNotificationWithSessionError() { @Test void testCreateElicitationWithNullCapabilities() { // Given - Create exchange with null capabilities - McpAsyncServerExchange asyncExchangeWithNullCapabilities = new McpAsyncServerExchange(mockSession, null, - clientInfo); + McpAsyncServerExchange asyncExchangeWithNullCapabilities = new McpAsyncServerExchange("testSessionId", + mockSession, null, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithNullCapabilities = new McpSyncServerExchange( asyncExchangeWithNullCapabilities); @@ -304,7 +306,7 @@ void testCreateElicitationWithNullCapabilities() { .build(); assertThatThrownBy(() -> exchangeWithNullCapabilities.createElicitation(elicitRequest)) - .isInstanceOf(McpError.class) + .isInstanceOf(IllegalStateException.class) .hasMessage("Client must be initialized. Call the initialize method first!"); // Verify that sendRequest was never called due to null capabilities @@ -318,8 +320,8 @@ void testCreateElicitationWithoutElicitationCapabilities() { .roots(true) .build(); - McpAsyncServerExchange asyncExchangeWithoutElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithoutElicitation, clientInfo); + McpAsyncServerExchange asyncExchangeWithoutElicitation = new McpAsyncServerExchange("testSessionId", + mockSession, capabilitiesWithoutElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithoutElicitation = new McpSyncServerExchange(asyncExchangeWithoutElicitation); McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() @@ -327,7 +329,7 @@ void testCreateElicitationWithoutElicitationCapabilities() { .build(); assertThatThrownBy(() -> exchangeWithoutElicitation.createElicitation(elicitRequest)) - .isInstanceOf(McpError.class) + .isInstanceOf(IllegalStateException.class) .hasMessage("Client must be configured with elicitation capabilities"); // Verify that sendRequest was never called due to missing elicitation @@ -342,8 +344,8 @@ void testCreateElicitationWithComplexRequest() { .elicitation() .build(); - McpAsyncServerExchange asyncExchangeWithElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithElicitation, clientInfo); + McpAsyncServerExchange asyncExchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); // Create a complex elicit request with schema @@ -386,8 +388,8 @@ void testCreateElicitationWithDeclineAction() { .elicitation() .build(); - McpAsyncServerExchange asyncExchangeWithElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithElicitation, clientInfo); + McpAsyncServerExchange asyncExchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() @@ -414,8 +416,8 @@ void testCreateElicitationWithCancelAction() { .elicitation() .build(); - McpAsyncServerExchange asyncExchangeWithElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithElicitation, clientInfo); + McpAsyncServerExchange asyncExchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() @@ -442,8 +444,8 @@ void testCreateElicitationWithSessionError() { .elicitation() .build(); - McpAsyncServerExchange asyncExchangeWithElicitation = new McpAsyncServerExchange(mockSession, - capabilitiesWithElicitation, clientInfo); + McpAsyncServerExchange asyncExchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() @@ -465,8 +467,8 @@ void testCreateElicitationWithSessionError() { @Test void testCreateMessageWithNullCapabilities() { - McpAsyncServerExchange asyncExchangeWithNullCapabilities = new McpAsyncServerExchange(mockSession, null, - clientInfo); + McpAsyncServerExchange asyncExchangeWithNullCapabilities = new McpAsyncServerExchange("testSessionId", + mockSession, null, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithNullCapabilities = new McpSyncServerExchange( asyncExchangeWithNullCapabilities); @@ -476,7 +478,7 @@ void testCreateMessageWithNullCapabilities() { .build(); assertThatThrownBy(() -> exchangeWithNullCapabilities.createMessage(createMessageRequest)) - .isInstanceOf(McpError.class) + .isInstanceOf(IllegalStateException.class) .hasMessage("Client must be initialized. Call the initialize method first!"); // Verify that sendRequest was never called due to null capabilities @@ -491,8 +493,8 @@ void testCreateMessageWithoutSamplingCapabilities() { .roots(true) .build(); - McpAsyncServerExchange asyncExchangeWithoutSampling = new McpAsyncServerExchange(mockSession, - capabilitiesWithoutSampling, clientInfo); + McpAsyncServerExchange asyncExchangeWithoutSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithoutSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithoutSampling = new McpSyncServerExchange(asyncExchangeWithoutSampling); McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() @@ -501,7 +503,7 @@ void testCreateMessageWithoutSamplingCapabilities() { .build(); assertThatThrownBy(() -> exchangeWithoutSampling.createMessage(createMessageRequest)) - .isInstanceOf(McpError.class) + .isInstanceOf(IllegalStateException.class) .hasMessage("Client must be configured with sampling capabilities"); // Verify that sendRequest was never called due to missing sampling capabilities @@ -516,8 +518,8 @@ void testCreateMessageWithBasicRequest() { .sampling() .build(); - McpAsyncServerExchange asyncExchangeWithSampling = new McpAsyncServerExchange(mockSession, - capabilitiesWithSampling, clientInfo); + McpAsyncServerExchange asyncExchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() @@ -553,8 +555,8 @@ void testCreateMessageWithImageContent() { .sampling() .build(); - McpAsyncServerExchange asyncExchangeWithSampling = new McpAsyncServerExchange(mockSession, - capabilitiesWithSampling, clientInfo); + McpAsyncServerExchange asyncExchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); // Create request with image content @@ -589,8 +591,8 @@ void testCreateMessageWithSessionError() { .sampling() .build(); - McpAsyncServerExchange asyncExchangeWithSampling = new McpAsyncServerExchange(mockSession, - capabilitiesWithSampling, clientInfo); + McpAsyncServerExchange asyncExchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() @@ -614,8 +616,8 @@ void testCreateMessageWithIncludeContext() { .sampling() .build(); - McpAsyncServerExchange asyncExchangeWithSampling = new McpAsyncServerExchange(mockSession, - capabilitiesWithSampling, clientInfo); + McpAsyncServerExchange asyncExchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, + capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() @@ -662,7 +664,7 @@ void testPingWithSuccessfulResponse() { @Test void testPingWithMcpError() { // Given - Mock an MCP-specific error during ping - McpError mcpError = new McpError("Server unavailable"); + McpError mcpError = McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Server unavailable").build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_PING), eq(null), any(TypeRef.class))) .thenReturn(Mono.error(mcpError)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateListingTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateListingTest.java index 61703c306..993ca717e 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateListingTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateListingTest.java @@ -41,10 +41,26 @@ void testTemplateResourcesFilteredFromRegularListing() { void testResourceListingWithMixedResources() { // Create resource list with both regular and template resources List allResources = List.of( - new McpSchema.Resource("file:///test/doc1.txt", "Document 1", "text/plain", null, null), - new McpSchema.Resource("file:///test/doc2.txt", "Document 2", "text/plain", null, null), - new McpSchema.Resource("file:///test/{type}/document.txt", "Typed Document", "text/plain", null, null), - new McpSchema.Resource("file:///users/{userId}/files/{fileId}", "User File", "text/plain", null, null)); + McpSchema.Resource.builder() + .uri("file:///test/doc1.txt") + .name("Document 1") + .mimeType("text/plain") + .build(), + McpSchema.Resource.builder() + .uri("file:///test/doc2.txt") + .name("Document 2") + .mimeType("text/plain") + .build(), + McpSchema.Resource.builder() + .uri("file:///test/{type}/document.txt") + .name("Typed Document") + .mimeType("text/plain") + .build(), + McpSchema.Resource.builder() + .uri("file:///users/{userId}/files/{fileId}") + .name("User File") + .mimeType("text/plain") + .build()); // Apply the filter logic from McpAsyncServer line 438 List filteredResources = allResources.stream() @@ -61,9 +77,16 @@ void testResourceListingWithMixedResources() { void testResourceTemplatesListedSeparately() { // Create mixed resources List resources = List.of( - new McpSchema.Resource("file:///test/regular.txt", "Regular Resource", "text/plain", null, null), - new McpSchema.Resource("file:///test/user/{userId}/profile.txt", "User Profile", "text/plain", null, - null)); + McpSchema.Resource.builder() + .uri("file:///test/regular.txt") + .name("Regular Resource") + .mimeType("text/plain") + .build(), + McpSchema.Resource.builder() + .uri("file:///test/user/{userId}/profile.txt") + .name("User Profile") + .mimeType("text/plain") + .build()); // Create explicit resource template McpSchema.ResourceTemplate explicitTemplate = new McpSchema.ResourceTemplate( diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java index a2030e468..54c45e561 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java @@ -51,7 +51,6 @@ void builderShouldCreateValidSyncToolSpecification() { assertThat(specification).isNotNull(); assertThat(specification.tool()).isEqualTo(tool); assertThat(specification.callHandler()).isNotNull(); - assertThat(specification.call()).isNull(); // deprecated field should be null } @Test @@ -140,7 +139,8 @@ void tearDown() { void defaultShouldThrowOnInvalidName() { Tool invalidTool = Tool.builder().name("invalid tool name").build(); - assertThatThrownBy(() -> McpServer.sync(transportProvider).tool(invalidTool, (exchange, args) -> null)) + assertThatThrownBy( + () -> McpServer.sync(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("invalid characters"); } @@ -150,7 +150,7 @@ void lenientDefaultShouldLogOnInvalidName() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); Tool invalidTool = Tool.builder().name("invalid tool name").build(); - assertThatCode(() -> McpServer.sync(transportProvider).tool(invalidTool, (exchange, args) -> null)) + assertThatCode(() -> McpServer.sync(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) .doesNotThrowAnyException(); assertThat(logAppender.list).hasSize(1); } @@ -161,7 +161,7 @@ void lenientConfigurationShouldLogOnInvalidName() { assertThatCode(() -> McpServer.sync(transportProvider) .strictToolNameValidation(false) - .tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException(); + .toolCall(invalidTool, (exchange, request) -> null)).doesNotThrowAnyException(); assertThat(logAppender.list).hasSize(1); } @@ -172,7 +172,7 @@ void serverConfigurationShouldOverrideDefault() { assertThatThrownBy(() -> McpServer.sync(transportProvider) .strictToolNameValidation(true) - .tool(invalidTool, (exchange, args) -> null)).isInstanceOf(IllegalArgumentException.class) + .toolCall(invalidTool, (exchange, request) -> null)).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("invalid characters"); } diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index 7220318c5..f25877cd3 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp-json-jackson2 jar @@ -70,7 +70,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT com.networknt diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapper.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapper.java deleted file mode 100644 index 4c69e9d34..000000000 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapper.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2025 - 2025 the original author or authors. - */ - -package io.modelcontextprotocol.json.jackson; - -import com.fasterxml.jackson.databind.JavaType; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.TypeRef; - -import java.io.IOException; - -/** - * Jackson-based implementation of JsonMapper. Wraps a Jackson ObjectMapper but keeps the - * SDK decoupled from Jackson at the API level. - * - * @deprecated since 18.0.0, use - * {@link io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper} instead. Will be - * removed in 19.0.0 - */ -@Deprecated(forRemoval = true, since = "18.0.0") -public final class JacksonMcpJsonMapper implements McpJsonMapper { - - private final ObjectMapper objectMapper; - - /** - * Constructs a new JacksonMcpJsonMapper instance with the given ObjectMapper. - * @param objectMapper the ObjectMapper to be used for JSON serialization and - * deserialization. Must not be null. - * @throws IllegalArgumentException if the provided ObjectMapper is null. - */ - public JacksonMcpJsonMapper(ObjectMapper objectMapper) { - if (objectMapper == null) { - throw new IllegalArgumentException("ObjectMapper must not be null"); - } - this.objectMapper = objectMapper; - } - - /** - * Returns the underlying Jackson {@link ObjectMapper} used for JSON serialization and - * deserialization. - * @return the ObjectMapper instance - */ - public ObjectMapper getObjectMapper() { - return objectMapper; - } - - @Override - public T readValue(String content, Class type) throws IOException { - return objectMapper.readValue(content, type); - } - - @Override - public T readValue(byte[] content, Class type) throws IOException { - return objectMapper.readValue(content, type); - } - - @Override - public T readValue(String content, TypeRef type) throws IOException { - JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType()); - return objectMapper.readValue(content, javaType); - } - - @Override - public T readValue(byte[] content, TypeRef type) throws IOException { - JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType()); - return objectMapper.readValue(content, javaType); - } - - @Override - public T convertValue(Object fromValue, Class type) { - return objectMapper.convertValue(fromValue, type); - } - - @Override - public T convertValue(Object fromValue, TypeRef type) { - JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType()); - return objectMapper.convertValue(fromValue, javaType); - } - - @Override - public String writeValueAsString(Object value) throws IOException { - return objectMapper.writeValueAsString(value); - } - - @Override - public byte[] writeValueAsBytes(Object value) throws IOException { - return objectMapper.writeValueAsBytes(value); - } - -} diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java deleted file mode 100644 index 8a7c0f42a..000000000 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2025 - 2025 the original author or authors. - */ - -package io.modelcontextprotocol.json.jackson; - -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.McpJsonMapperSupplier; - -/** - * A supplier of {@link McpJsonMapper} instances that uses the Jackson library for JSON - * serialization and deserialization. - *

- * This implementation provides a {@link McpJsonMapper} backed by a Jackson - * {@link com.fasterxml.jackson.databind.ObjectMapper}. - * - * @deprecated since 18.0.0, use - * {@link io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier} instead. - * Will be removed in 19.0.0. - */ -@Deprecated(forRemoval = true, since = "18.0.0") -public class JacksonMcpJsonMapperSupplier implements McpJsonMapperSupplier { - - /** - * Returns a new instance of {@link McpJsonMapper} that uses the Jackson library for - * JSON serialization and deserialization. - *

- * The returned {@link McpJsonMapper} is backed by a new instance of - * {@link com.fasterxml.jackson.databind.ObjectMapper}. - * @return a new {@link McpJsonMapper} instance - */ - @Override - public McpJsonMapper get() { - return new JacksonMcpJsonMapper(new com.fasterxml.jackson.databind.ObjectMapper()); - } - -} diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java deleted file mode 100644 index 002a9d2a9..000000000 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ -package io.modelcontextprotocol.json.schema.jackson; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.networknt.schema.Schema; -import com.networknt.schema.SchemaRegistry; -import com.networknt.schema.Error; -import com.networknt.schema.dialect.Dialects; -import io.modelcontextprotocol.json.schema.JsonSchemaValidator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Default implementation of the {@link JsonSchemaValidator} interface. This class - * provides methods to validate structured content against a JSON schema. It uses the - * NetworkNT JSON Schema Validator library for validation. - * - * @author Christian Tzolov - * @deprecated since 18.0.0, use - * {@link io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator} - * instead. Will be removed in 19.0.0. - */ -@Deprecated(forRemoval = true, since = "18.0.0") -public class DefaultJsonSchemaValidator implements JsonSchemaValidator { - - private static final Logger logger = LoggerFactory.getLogger(DefaultJsonSchemaValidator.class); - - private final ObjectMapper objectMapper; - - private final SchemaRegistry schemaFactory; - - // TODO: Implement a strategy to purge the cache (TTL, size limit, etc.) - private final ConcurrentHashMap schemaCache; - - public DefaultJsonSchemaValidator() { - this(new ObjectMapper()); - } - - public DefaultJsonSchemaValidator(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - this.schemaFactory = SchemaRegistry.withDialect(Dialects.getDraft202012()); - this.schemaCache = new ConcurrentHashMap<>(); - } - - @Override - public ValidationResponse validate(Map schema, Object structuredContent) { - - if (schema == null) { - throw new IllegalArgumentException("Schema must not be null"); - } - if (structuredContent == null) { - throw new IllegalArgumentException("Structured content must not be null"); - } - - try { - - JsonNode jsonStructuredOutput = (structuredContent instanceof String) - ? this.objectMapper.readTree((String) structuredContent) - : this.objectMapper.valueToTree(structuredContent); - - List validationResult = this.getOrCreateJsonSchema(schema).validate(jsonStructuredOutput); - - // Check if validation passed - if (!validationResult.isEmpty()) { - return ValidationResponse - .asInvalid("Validation failed: structuredContent does not match tool outputSchema. " - + "Validation errors: " + validationResult); - } - - return ValidationResponse.asValid(jsonStructuredOutput.toString()); - - } - catch (JsonProcessingException e) { - logger.error("Failed to validate CallToolResult: Error parsing schema: {}", e); - return ValidationResponse.asInvalid("Error parsing tool JSON Schema: " + e.getMessage()); - } - catch (Exception e) { - logger.error("Failed to validate CallToolResult: Unexpected error: {}", e); - return ValidationResponse.asInvalid("Unexpected validation error: " + e.getMessage()); - } - } - - /** - * Gets a cached Schema or creates and caches a new one. - * @param schema the schema map to convert - * @return the compiled Schema - * @throws JsonProcessingException if schema processing fails - */ - private Schema getOrCreateJsonSchema(Map schema) throws JsonProcessingException { - // Generate cache key based on schema content - String cacheKey = this.generateCacheKey(schema); - - // Try to get from cache first - Schema cachedSchema = this.schemaCache.get(cacheKey); - if (cachedSchema != null) { - return cachedSchema; - } - - // Create new schema if not in cache - Schema newSchema = this.createJsonSchema(schema); - - // Cache the schema - Schema existingSchema = this.schemaCache.putIfAbsent(cacheKey, newSchema); - return existingSchema != null ? existingSchema : newSchema; - } - - /** - * Creates a new Schema from the given schema map. - * @param schema the schema map - * @return the compiled Schema - * @throws JsonProcessingException if schema processing fails - */ - private Schema createJsonSchema(Map schema) throws JsonProcessingException { - // Convert schema map directly to JsonNode (more efficient than string - // serialization) - JsonNode schemaNode = this.objectMapper.valueToTree(schema); - - // Handle case where ObjectMapper might return null (e.g., in mocked scenarios) - if (schemaNode == null) { - throw new JsonProcessingException("Failed to convert schema to JsonNode") { - }; - } - - return this.schemaFactory.getSchema(schemaNode); - } - - /** - * Generates a cache key for the given schema map. - * @param schema the schema map - * @return a cache key string - */ - protected String generateCacheKey(Map schema) { - if (schema.containsKey("$id")) { - // Use the (optional) "$id" field as the cache key if present - return "" + schema.get("$id"); - } - // Fall back to schema's hash code as a simple cache key - // For more sophisticated caching, could use content-based hashing - return String.valueOf(schema.hashCode()); - } - - /** - * Clears the schema cache. Useful for testing or memory management. - */ - public void clearCache() { - this.schemaCache.clear(); - } - - /** - * Returns the current size of the schema cache. - * @return the number of cached schemas - */ - public int getCacheSize() { - return this.schemaCache.size(); - } - -} diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/JacksonJsonSchemaValidatorSupplier.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/JacksonJsonSchemaValidatorSupplier.java deleted file mode 100644 index ae16d66e9..000000000 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/JacksonJsonSchemaValidatorSupplier.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2025 - 2025 the original author or authors. - */ - -package io.modelcontextprotocol.json.schema.jackson; - -import io.modelcontextprotocol.json.schema.JsonSchemaValidator; -import io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier; - -/** - * A concrete implementation of {@link JsonSchemaValidatorSupplier} that provides a - * {@link JsonSchemaValidator} instance based on the Jackson library. - * - * @see JsonSchemaValidatorSupplier - * @see JsonSchemaValidator - * @deprecated since 18.0.0, use - * {@link io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier} - * instead. Will be removed in 19.0.0. - */ -public class JacksonJsonSchemaValidatorSupplier implements JsonSchemaValidatorSupplier { - - /** - * Returns a new instance of {@link JsonSchemaValidator} that uses the Jackson library - * for JSON schema validation. - * @return A {@link JsonSchemaValidator} instance. - */ - @Override - public JsonSchemaValidator get() { - return new DefaultJsonSchemaValidator(); - } - -} diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/DefaultJsonSchemaValidatorTests.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/DefaultJsonSchemaValidatorTests.java deleted file mode 100644 index 66cba09b8..000000000 --- a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/DefaultJsonSchemaValidatorTests.java +++ /dev/null @@ -1,809 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.json.jackson; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - -import java.util.List; -import java.util.Map; -import java.util.stream.Stream; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; -import io.modelcontextprotocol.json.schema.jackson.DefaultJsonSchemaValidator; - -/** - * Tests for {@link DefaultJsonSchemaValidator}. - * - * @author Christian Tzolov - */ -@Deprecated(forRemoval = true) -class DefaultJsonSchemaValidatorTests { - - private DefaultJsonSchemaValidator validator; - - private ObjectMapper objectMapper; - - @Mock - private ObjectMapper mockObjectMapper; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - validator = new DefaultJsonSchemaValidator(); - objectMapper = new ObjectMapper(); - } - - /** - * Utility method to convert JSON string to Map - */ - private Map toMap(String json) { - try { - return objectMapper.readValue(json, new TypeReference>() { - }); - } - catch (Exception e) { - throw new RuntimeException("Failed to parse JSON: " + json, e); - } - } - - private List> toListMap(String json) { - try { - return objectMapper.readValue(json, new TypeReference>>() { - }); - } - catch (Exception e) { - throw new RuntimeException("Failed to parse JSON: " + json, e); - } - } - - @Test - void testDefaultConstructor() { - DefaultJsonSchemaValidator defaultValidator = new DefaultJsonSchemaValidator(); - - String schemaJson = """ - { - "type": "object", - "properties": { - "test": {"type": "string"} - } - } - """; - String contentJson = """ - { - "test": "value" - } - """; - - ValidationResponse response = defaultValidator.validate(toMap(schemaJson), toMap(contentJson)); - assertTrue(response.valid()); - } - - @Test - void testConstructorWithObjectMapper() { - ObjectMapper customMapper = new ObjectMapper(); - DefaultJsonSchemaValidator customValidator = new DefaultJsonSchemaValidator(customMapper); - - String schemaJson = """ - { - "type": "object", - "properties": { - "test": {"type": "string"} - } - } - """; - String contentJson = """ - { - "test": "value" - } - """; - - ValidationResponse response = customValidator.validate(toMap(schemaJson), toMap(contentJson)); - assertTrue(response.valid()); - } - - @Test - void testValidateWithValidStringSchema() { - String schemaJson = """ - { - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - }, - "required": ["name", "age"] - } - """; - - String contentJson = """ - { - "name": "John Doe", - "age": 30 - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - assertNotNull(response.jsonStructuredOutput()); - } - - @Test - void testValidateWithValidNumberSchema() { - String schemaJson = """ - { - "type": "object", - "properties": { - "price": {"type": "number", "minimum": 0}, - "quantity": {"type": "integer", "minimum": 1} - }, - "required": ["price", "quantity"] - } - """; - - String contentJson = """ - { - "price": 19.99, - "quantity": 5 - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - } - - @Test - void testValidateWithValidArraySchema() { - String schemaJson = """ - { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": {"type": "string"} - } - }, - "required": ["items"] - } - """; - - String contentJson = """ - { - "items": ["apple", "banana", "cherry"] - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - } - - @Test - void testValidateWithValidArraySchemaTopLevelArray() { - String schemaJson = """ - { - "$schema" : "https://json-schema.org/draft/2020-12/schema", - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "city" : { - "type" : "string" - }, - "summary" : { - "type" : "string" - }, - "temperatureC" : { - "type" : "number", - "format" : "float" - } - }, - "required" : [ "city", "summary", "temperatureC" ] - }, - "additionalProperties" : false - } - """; - - String contentJson = """ - [ - { - "city": "London", - "summary": "Generally mild with frequent rainfall. Winters are cool and damp, summers are warm but rarely hot. Cloudy conditions are common throughout the year.", - "temperatureC": 11.3 - }, - { - "city": "New York", - "summary": "Four distinct seasons with hot and humid summers, cold winters with snow, and mild springs and autumns. Precipitation is fairly evenly distributed throughout the year.", - "temperatureC": 12.8 - }, - { - "city": "San Francisco", - "summary": "Mild year-round with a distinctive Mediterranean climate. Famous for summer fog, mild winters, and little temperature variation throughout the year. Very little rainfall in summer months.", - "temperatureC": 14.6 - }, - { - "city": "Tokyo", - "summary": "Humid subtropical climate with hot, wet summers and mild winters. Experiences a rainy season in early summer and occasional typhoons in late summer to early autumn.", - "temperatureC": 15.4 - } - ] - """; - - Map schema = toMap(schemaJson); - - // Validate as JSON string - ValidationResponse response = validator.validate(schema, contentJson); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - - List> structuredContent = toListMap(contentJson); - - // Validate as List> - response = validator.validate(schema, structuredContent); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - } - - @Test - void testValidateWithInvalidTypeSchema() { - String schemaJson = """ - { - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - }, - "required": ["name", "age"] - } - """; - - String contentJson = """ - { - "name": "John Doe", - "age": "thirty" - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertFalse(response.valid()); - assertNotNull(response.errorMessage()); - assertTrue(response.errorMessage().contains("Validation failed")); - assertTrue(response.errorMessage().contains("structuredContent does not match tool outputSchema")); - } - - @Test - void testValidateWithMissingRequiredField() { - String schemaJson = """ - { - "type": "object", - "properties": { - "name": {"type": "string"}, - "age": {"type": "integer"} - }, - "required": ["name", "age"] - } - """; - - String contentJson = """ - { - "name": "John Doe" - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertFalse(response.valid()); - assertNotNull(response.errorMessage()); - assertTrue(response.errorMessage().contains("Validation failed")); - } - - @Test - void testValidateWithAdditionalPropertiesNotAllowed() { - String schemaJson = """ - { - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "required": ["name"], - "additionalProperties": false - } - """; - - String contentJson = """ - { - "name": "John Doe", - "extraField": "should not be allowed" - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertFalse(response.valid()); - assertNotNull(response.errorMessage()); - assertTrue(response.errorMessage().contains("Validation failed")); - } - - @Test - void testValidateWithAdditionalPropertiesExplicitlyAllowed() { - String schemaJson = """ - { - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "required": ["name"], - "additionalProperties": true - } - """; - - String contentJson = """ - { - "name": "John Doe", - "extraField": "should be allowed" - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - } - - @Test - void testValidateWithDefaultAdditionalProperties() { - String schemaJson = """ - { - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "required": ["name"], - "additionalProperties": true - } - """; - - String contentJson = """ - { - "name": "John Doe", - "extraField": "should be allowed" - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - } - - @Test - void testValidateWithAdditionalPropertiesExplicitlyDisallowed() { - String schemaJson = """ - { - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "required": ["name"], - "additionalProperties": false - } - """; - - String contentJson = """ - { - "name": "John Doe", - "extraField": "should not be allowed" - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertFalse(response.valid()); - assertNotNull(response.errorMessage()); - assertTrue(response.errorMessage().contains("Validation failed")); - } - - @Test - void testValidateWithEmptySchema() { - String schemaJson = """ - { - "additionalProperties": true - } - """; - - String contentJson = """ - { - "anything": "goes" - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - } - - @Test - void testValidateWithEmptyContent() { - String schemaJson = """ - { - "type": "object", - "properties": {} - } - """; - - String contentJson = """ - {} - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - } - - @Test - void testValidateWithNestedObjectSchema() { - String schemaJson = """ - { - "type": "object", - "properties": { - "person": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "address": { - "type": "object", - "properties": { - "street": {"type": "string"}, - "city": {"type": "string"} - }, - "required": ["street", "city"] - } - }, - "required": ["name", "address"] - } - }, - "required": ["person"] - } - """; - - String contentJson = """ - { - "person": { - "name": "John Doe", - "address": { - "street": "123 Main St", - "city": "Anytown" - } - } - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertTrue(response.valid()); - assertNull(response.errorMessage()); - } - - @Test - void testValidateWithInvalidNestedObjectSchema() { - String schemaJson = """ - { - "type": "object", - "properties": { - "person": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "address": { - "type": "object", - "properties": { - "street": {"type": "string"}, - "city": {"type": "string"} - }, - "required": ["street", "city"] - } - }, - "required": ["name", "address"] - } - }, - "required": ["person"] - } - """; - - String contentJson = """ - { - "person": { - "name": "John Doe", - "address": { - "street": "123 Main St" - } - } - } - """; - - Map schema = toMap(schemaJson); - Map structuredContent = toMap(contentJson); - - ValidationResponse response = validator.validate(schema, structuredContent); - - assertFalse(response.valid()); - assertNotNull(response.errorMessage()); - assertTrue(response.errorMessage().contains("Validation failed")); - } - - @Test - void testValidateWithJsonProcessingException() throws Exception { - DefaultJsonSchemaValidator validatorWithMockMapper = new DefaultJsonSchemaValidator(mockObjectMapper); - - Map schema = Map.of("type", "object"); - Map structuredContent = Map.of("key", "value"); - - // This will trigger our null check and throw JsonProcessingException - when(mockObjectMapper.valueToTree(any())).thenReturn(null); - - ValidationResponse response = validatorWithMockMapper.validate(schema, structuredContent); - - assertFalse(response.valid()); - assertNotNull(response.errorMessage()); - assertTrue(response.errorMessage().contains("Error parsing tool JSON Schema")); - assertTrue(response.errorMessage().contains("Failed to convert schema to JsonNode")); - } - - @ParameterizedTest - @MethodSource("provideValidSchemaAndContentPairs") - void testValidateWithVariousValidInputs(Map schema, Map content) { - ValidationResponse response = validator.validate(schema, content); - - assertTrue(response.valid(), "Expected validation to pass for schema: " + schema + " and content: " + content); - assertNull(response.errorMessage()); - } - - @ParameterizedTest - @MethodSource("provideInvalidSchemaAndContentPairs") - void testValidateWithVariousInvalidInputs(Map schema, Map content) { - ValidationResponse response = validator.validate(schema, content); - - assertFalse(response.valid(), "Expected validation to fail for schema: " + schema + " and content: " + content); - assertNotNull(response.errorMessage()); - assertTrue(response.errorMessage().contains("Validation failed")); - } - - private static Map staticToMap(String json) { - try { - ObjectMapper mapper = new ObjectMapper(); - return mapper.readValue(json, new TypeReference>() { - }); - } - catch (Exception e) { - throw new RuntimeException("Failed to parse JSON: " + json, e); - } - } - - private static Stream provideValidSchemaAndContentPairs() { - return Stream.of( - // Boolean schema - Arguments.of(staticToMap(""" - { - "type": "object", - "properties": { - "flag": {"type": "boolean"} - } - } - """), staticToMap(""" - { - "flag": true - } - """)), - // String with additional properties allowed - Arguments.of(staticToMap(""" - { - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": true - } - """), staticToMap(""" - { - "name": "test", - "extra": "allowed" - } - """)), - // Array with specific items - Arguments.of(staticToMap(""" - { - "type": "object", - "properties": { - "numbers": { - "type": "array", - "items": {"type": "number"} - } - } - } - """), staticToMap(""" - { - "numbers": [1.0, 2.5, 3.14] - } - """)), - // Enum validation - Arguments.of(staticToMap(""" - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["active", "inactive", "pending"] - } - } - } - """), staticToMap(""" - { - "status": "active" - } - """))); - } - - private static Stream provideInvalidSchemaAndContentPairs() { - return Stream.of( - // Wrong boolean type - Arguments.of(staticToMap(""" - { - "type": "object", - "properties": { - "flag": {"type": "boolean"} - } - } - """), staticToMap(""" - { - "flag": "true" - } - """)), - // Array with wrong item types - Arguments.of(staticToMap(""" - { - "type": "object", - "properties": { - "numbers": { - "type": "array", - "items": {"type": "number"} - } - } - } - """), staticToMap(""" - { - "numbers": ["one", "two", "three"] - } - """)), - // Invalid enum value - Arguments.of(staticToMap(""" - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["active", "inactive", "pending"] - } - } - } - """), staticToMap(""" - { - "status": "unknown" - } - """)), - // Minimum constraint violation - Arguments.of(staticToMap(""" - { - "type": "object", - "properties": { - "age": {"type": "integer", "minimum": 0} - } - } - """), staticToMap(""" - { - "age": -5 - } - """))); - } - - @Test - void testValidationResponseToValid() { - String jsonOutput = "{\"test\":\"value\"}"; - ValidationResponse response = ValidationResponse.asValid(jsonOutput); - assertTrue(response.valid()); - assertNull(response.errorMessage()); - assertEquals(jsonOutput, response.jsonStructuredOutput()); - } - - @Test - void testValidationResponseToInvalid() { - String errorMessage = "Test error message"; - ValidationResponse response = ValidationResponse.asInvalid(errorMessage); - assertFalse(response.valid()); - assertEquals(errorMessage, response.errorMessage()); - assertNull(response.jsonStructuredOutput()); - } - - @Test - void testValidationResponseRecord() { - ValidationResponse response1 = new ValidationResponse(true, null, "{\"valid\":true}"); - ValidationResponse response2 = new ValidationResponse(false, "Error", null); - - assertTrue(response1.valid()); - assertNull(response1.errorMessage()); - assertEquals("{\"valid\":true}", response1.jsonStructuredOutput()); - - assertFalse(response2.valid()); - assertEquals("Error", response2.errorMessage()); - assertNull(response2.jsonStructuredOutput()); - - // Test equality - ValidationResponse response3 = new ValidationResponse(true, null, "{\"valid\":true}"); - assertEquals(response1, response3); - assertNotEquals(response1, response2); - } - -} diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index 55db3417f..99baf14e1 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp-json-jackson3 jar @@ -64,7 +64,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT tools.jackson.core 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/pom.xml b/mcp-test/pom.xml index f08ccc883..531c0bbc5 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp-test jar @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT @@ -159,7 +159,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT test @@ -170,7 +170,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT test diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java index 240732ebe..7755ce456 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java @@ -82,7 +82,10 @@ void testToolCallSuccess(String clientType) { var clientBuilder = clientBuilders.get(clientType); - var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); + var callResponse = McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("CALL RESPONSE"))) + .isError(false) + .build(); McpStatelessServerFeatures.SyncToolSpecification tool1 = McpStatelessServerFeatures.SyncToolSpecification .builder() .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) @@ -170,7 +173,10 @@ void testToolListChangeHandlingSuccess(String clientType) { var clientBuilder = clientBuilders.get(clientType); - var callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); + var callResponse = McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("CALL RESPONSE"))) + .isError(false) + .build(); McpStatelessServerFeatures.SyncToolSpecification tool1 = McpStatelessServerFeatures.SyncToolSpecification .builder() .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) 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 e8d24a379..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,6 +45,7 @@ public abstract class AbstractMcpAsyncClientResiliencyTests { private static final Logger logger = LoggerFactory.getLogger(AbstractMcpAsyncClientResiliencyTests.class); static Network network = Network.newNetwork(); + public static String host = "http://localhost:3001"; @SuppressWarnings("resource") diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java index d6677ec9a..9cd1191d1 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java @@ -95,26 +95,6 @@ void testImmediateClose() { // --------------------------------------- // Tools Tests // --------------------------------------- - @Test - @Deprecated - void testAddTool() { - Tool newTool = McpSchema.Tool.builder() - .name("new-tool") - .title("New test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - StepVerifier - .create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolSpecification(newTool, - (exchange, args) -> Mono.just(CallToolResult.builder().content(List.of()).isError(false).build())))) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - @Test void testAddToolCall() { Tool newTool = McpSchema.Tool.builder() @@ -136,29 +116,6 @@ void testAddToolCall() { assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); } - @Test - @Deprecated - void testAddDuplicateTool() { - Tool duplicateTool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(duplicateTool, - (exchange, args) -> Mono.just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(); - - StepVerifier - .create(mcpAsyncServer.addTool(new McpServerFeatures.AsyncToolSpecification(duplicateTool, - (exchange, args) -> Mono.just(CallToolResult.builder().content(List.of()).isError(false).build())))) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - @Test void testAddDuplicateToolCall() { Tool duplicateTool = McpSchema.Tool.builder() diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java index 0a59d0aae..eee5f1a4d 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -99,25 +99,6 @@ void testGetAsyncServer() { // Tools Tests // --------------------------------------- - @Test - @Deprecated - void testAddTool() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - Tool newTool = McpSchema.Tool.builder() - .name("new-tool") - .title("New test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - assertThatCode(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolSpecification(newTool, - (exchange, args) -> CallToolResult.builder().content(List.of()).isError(false).build()))) - .doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - @Test void testAddToolCall() { var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") @@ -138,27 +119,6 @@ void testAddToolCall() { assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); } - @Test - @Deprecated - void testAddDuplicateTool() { - Tool duplicateTool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Duplicate tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tool(duplicateTool, (exchange, args) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.addTool(new McpServerFeatures.SyncToolSpecification(duplicateTool, - (exchange, args) -> CallToolResult.builder().content(List.of()).isError(false).build()))) - .doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - @Test void testAddDuplicateToolCall() { Tool duplicateTool = McpSchema.Tool.builder() diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java index f93e760f1..4e74dac3e 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java @@ -13,6 +13,7 @@ import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; import reactor.core.publisher.Mono; @@ -29,7 +30,7 @@ public class MockMcpClientTransport implements McpClientTransport { private final BiConsumer interceptor; - private String protocolVersion = McpSchema.LATEST_PROTOCOL_VERSION; + private String protocolVersion = ProtocolVersions.MCP_2025_11_25; public MockMcpClientTransport() { this((t, msg) -> { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java index 612a65898..47a229afd 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java @@ -13,6 +13,7 @@ import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.MockMcpClientTransport; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; import io.modelcontextprotocol.spec.McpSchema.InitializeResult; import io.modelcontextprotocol.spec.McpSchema.PaginatedRequest; @@ -42,7 +43,7 @@ private static MockMcpClientTransport initializationEnabledTransport() { private static MockMcpClientTransport initializationEnabledTransport( McpSchema.ServerCapabilities mockServerCapabilities, McpSchema.Implementation mockServerInfo) { - McpSchema.InitializeResult mockInitResult = new McpSchema.InitializeResult(McpSchema.LATEST_PROTOCOL_VERSION, + McpSchema.InitializeResult mockInitResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2025_11_25, mockServerCapabilities, mockServerInfo, "Test instructions"); return new MockMcpClientTransport((t, message) -> { @@ -51,7 +52,7 @@ private static MockMcpClientTransport initializationEnabledTransport( r.id(), mockInitResult, null); t.simulateIncomingMessage(initResponse); } - }).withProtocolVersion(McpSchema.LATEST_PROTOCOL_VERSION); + }).withProtocolVersion(ProtocolVersions.MCP_2025_11_25); } @Test @@ -212,8 +213,12 @@ void testResourcesChangeNotificationHandling() { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock resources list that the server will return - McpSchema.Resource mockResource = new McpSchema.Resource("test://resource", "Test Resource", "A test resource", - "text/plain", null); + McpSchema.Resource mockResource = McpSchema.Resource.builder() + .uri("test://resource") + .name("Test Resource") + .description("A test resource") + .mimeType("text/plain") + .build(); McpSchema.ListResourcesResult mockResourcesResult = new McpSchema.ListResourcesResult(List.of(mockResource), null); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java index a94b9b6a7..03f64aa64 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java @@ -11,6 +11,7 @@ import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.InitializeResult; import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.spec.ProtocolVersions; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -68,7 +69,7 @@ void shouldNegotiateSpecificVersion() { .requestTimeout(REQUEST_TIMEOUT) .build(); - client.setProtocolVersions(List.of(oldVersion, McpSchema.LATEST_PROTOCOL_VERSION)); + client.setProtocolVersions(List.of(oldVersion, ProtocolVersions.MCP_2025_11_25)); try { Mono initializeResultMono = client.initialize(); @@ -77,7 +78,7 @@ void shouldNegotiateSpecificVersion() { McpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); assertThat(request.params()).isInstanceOf(McpSchema.InitializeRequest.class); McpSchema.InitializeRequest initRequest = (McpSchema.InitializeRequest) request.params(); - assertThat(initRequest.protocolVersion()).isIn(List.of(oldVersion, McpSchema.LATEST_PROTOCOL_VERSION)); + assertThat(initRequest.protocolVersion()).isIn(List.of(oldVersion, ProtocolVersions.MCP_2025_11_25)); transport.simulateIncomingMessage(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), new McpSchema.InitializeResult(oldVersion, ServerCapabilities.builder().build(), @@ -123,7 +124,7 @@ void shouldFailForUnsupportedVersion() { void shouldUseHighestVersionWhenMultipleSupported() { String oldVersion = "0.1.0"; String middleVersion = "0.2.0"; - String latestVersion = McpSchema.LATEST_PROTOCOL_VERSION; + String latestVersion = ProtocolVersions.MCP_2025_11_25; MockMcpClientTransport transport = new MockMcpClientTransport(); McpAsyncClient client = McpClient.async(transport) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index 8ddd54266..f88736a5d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java @@ -8,6 +8,7 @@ import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; import java.net.URI; import java.net.URISyntaxException; import java.util.Map; @@ -78,7 +79,7 @@ void testRequestCustomizer() throws URISyntaxException { withTransport(transport, (t) -> { // Send test message - var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, + var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, 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, @@ -108,7 +109,7 @@ void testAsyncRequestCustomizer() throws URISyntaxException { withTransport(transport, (t) -> { // Send test message - var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, + var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, 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, @@ -131,7 +132,7 @@ void testCloseUninitialized() { StepVerifier.create(transport.closeGracefully()).verifyComplete(); - var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, + var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, 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, @@ -146,7 +147,7 @@ void testCloseUninitialized() { void testCloseInitialized() { var transport = HttpClientStreamableHttpTransport.builder(host).build(); - var initializeRequest = new McpSchema.InitializeRequest(McpSchema.LATEST_PROTOCOL_VERSION, + var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, 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, diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java index 8b2dea462..ce381436d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java @@ -132,8 +132,10 @@ public class AsyncServerMcpTransportContextIntegrationTests { private final BiFunction> asyncStatelessHandler = ( transportContext, request) -> { - return Mono - .just(new McpSchema.CallToolResult(transportContext.get("server-side-header-value").toString(), null)); + return Mono.just(McpSchema.CallToolResult.builder() + .addTextContent(transportContext.get("server-side-header-value").toString()) + .isError(false) + .build()); }; private final BiFunction> asyncStatefulHandler = ( @@ -198,7 +200,10 @@ void asyncClientStreamableServer() { var mcpServer = McpServer.async(streamableServerTransport) .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.AsyncToolSpecification(tool, null, asyncStatefulHandler)) + .tools(McpServerFeatures.AsyncToolSpecification.builder() + .tool(tool) + .callHandler(asyncStatefulHandler) + .build()) .build(); StepVerifier.create(asyncStreamableClient.initialize()).assertNext(initResult -> { @@ -229,7 +234,10 @@ void asyncClientSseServer() { var mcpServer = McpServer.async(sseServerTransport) .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.AsyncToolSpecification(tool, null, asyncStatefulHandler)) + .tools(McpServerFeatures.AsyncToolSpecification.builder() + .tool(tool) + .callHandler(asyncStatefulHandler) + .build()) .build(); StepVerifier.create(asyncSseClient.initialize()).assertNext(initResult -> { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java index 614172b84..29eef1410 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java @@ -47,12 +47,14 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests { .build(); private final BiFunction toolHandler = ( - exchange, request) -> new McpSchema.CallToolResult( - exchange.transportContext().get("protocol-version").toString(), null); + exchange, request) -> McpSchema.CallToolResult.builder() + .addTextContent(exchange.transportContext().get("protocol-version").toString()) + .isError(false) + .build(); McpSyncServer mcpServer = McpServer.sync(transport) .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()) - .tools(new McpServerFeatures.SyncToolSpecification(toolSpec, null, toolHandler)) + .tools(McpServerFeatures.SyncToolSpecification.builder().tool(toolSpec).callHandler(toolHandler).build()) .build(); @AfterEach diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java index cc8f4c4be..563e2167d 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java @@ -78,8 +78,10 @@ public class SyncServerMcpTransportContextIntegrationTests { }; private final BiFunction statelessHandler = ( - transportContext, - request) -> new McpSchema.CallToolResult(transportContext.get("server-side-header-value").toString(), null); + transportContext, request) -> McpSchema.CallToolResult.builder() + .addTextContent(transportContext.get("server-side-header-value").toString()) + .isError(false) + .build(); private final BiFunction statefulHandler = ( exchange, request) -> statelessHandler.apply(exchange.transportContext(), request); @@ -172,7 +174,7 @@ void streamableServer() { var mcpServer = McpServer.sync(streamableServerTransport) .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.SyncToolSpecification(tool, null, statefulHandler)) + .tools(McpServerFeatures.SyncToolSpecification.builder().tool(tool).callHandler(statefulHandler).build()) .build(); McpSchema.InitializeResult initResult = streamableClient.initialize(); @@ -198,7 +200,7 @@ void sseServer() { var mcpServer = McpServer.sync(sseServerTransport) .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) - .tools(new McpServerFeatures.SyncToolSpecification(tool, null, statefulHandler)) + .tools(McpServerFeatures.SyncToolSpecification.builder().tool(tool).callHandler(statefulHandler).build()) .build(); McpSchema.InitializeResult initResult = sseClient.initialize(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java index cdd2bacb7..d9f899020 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java @@ -10,6 +10,7 @@ import io.modelcontextprotocol.MockMcpServerTransport; import io.modelcontextprotocol.MockMcpServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -36,8 +37,7 @@ void shouldUseLatestVersionByDefault() { String requestId = UUID.randomUUID().toString(); - transportProvider - .simulateIncomingMessage(jsonRpcInitializeRequest(requestId, McpSchema.LATEST_PROTOCOL_VERSION)); + transportProvider.simulateIncomingMessage(jsonRpcInitializeRequest(requestId, ProtocolVersions.MCP_2025_11_25)); McpSchema.JSONRPCMessage response = serverTransport.getLastSentMessage(); assertThat(response).isInstanceOf(McpSchema.JSONRPCResponse.class); @@ -60,7 +60,7 @@ void shouldNegotiateSpecificVersion() { McpAsyncServer server = McpServer.async(transportProvider).serverInfo(SERVER_INFO).build(); - server.setProtocolVersions(List.of(oldVersion, McpSchema.LATEST_PROTOCOL_VERSION)); + server.setProtocolVersions(List.of(oldVersion, ProtocolVersions.MCP_2025_11_25)); String requestId = UUID.randomUUID().toString(); @@ -105,7 +105,7 @@ void shouldSuggestLatestVersionForUnsupportedVersion() { void shouldUseHighestVersionWhenMultipleSupported() { String oldVersion = "0.1.0"; String middleVersion = "0.2.0"; - String latestVersion = McpSchema.LATEST_PROTOCOL_VERSION; + String latestVersion = ProtocolVersions.MCP_2025_11_25; MockMcpServerTransport serverTransport = new MockMcpServerTransport(); var transportProvider = new MockMcpServerTransportProvider(serverTransport); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java index 873d48e36..5390cc4c2 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java @@ -185,7 +185,7 @@ void shouldHandleNotificationBeforeSessionFactoryIsSet() { // Send notification before setting session factory StepVerifier.create(transportProvider.notifyClients("testNotification", Map.of("key", "value"))) .verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(McpError.class); + assertThat(error).isInstanceOf(IllegalStateException.class); }); } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index c732b1cc1..942e0a6e2 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -17,10 +17,10 @@ import java.util.List; import java.util.Map; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; -import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import net.javacrumbs.jsonunit.core.Option; /** @@ -72,7 +72,7 @@ void testContentDeserializationWrongType() { @Test void testImageContent() throws Exception { - McpSchema.ImageContent test = new McpSchema.ImageContent(null, null, "base64encodeddata", "image/png"); + McpSchema.ImageContent test = new McpSchema.ImageContent(null, "base64encodeddata", "image/png"); String value = JSON_MAPPER.writeValueAsString(test); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -160,7 +160,7 @@ void testEmbeddedResource() throws Exception { McpSchema.TextResourceContents resourceContents = new McpSchema.TextResourceContents("resource://test", "text/plain", "Sample resource content"); - McpSchema.EmbeddedResource test = new McpSchema.EmbeddedResource(null, null, resourceContents); + McpSchema.EmbeddedResource test = new McpSchema.EmbeddedResource(null, resourceContents); String value = JSON_MAPPER.writeValueAsString(test); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -191,7 +191,7 @@ void testEmbeddedResourceWithBlobContents() throws Exception { McpSchema.BlobResourceContents resourceContents = new McpSchema.BlobResourceContents("resource://test", "application/octet-stream", "base64encodedblob"); - McpSchema.EmbeddedResource test = new McpSchema.EmbeddedResource(null, null, resourceContents); + McpSchema.EmbeddedResource test = new McpSchema.EmbeddedResource(null, resourceContents); String value = JSON_MAPPER.writeValueAsString(test); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -366,8 +366,13 @@ void testResource() throws Exception { McpSchema.Annotations annotations = new McpSchema.Annotations( Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.8); - McpSchema.Resource resource = new McpSchema.Resource("resource://test", "Test Resource", "A test resource", - "text/plain", annotations); + McpSchema.Resource resource = McpSchema.Resource.builder() + .uri("resource://test") + .name("Test Resource") + .description("A test resource") + .mimeType("text/plain") + .annotations(annotations) + .build(); String value = JSON_MAPPER.writeValueAsString(resource); assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) @@ -451,11 +456,19 @@ void testResourceTemplate() throws Exception { @Test void testListResourcesResult() throws Exception { - McpSchema.Resource resource1 = new McpSchema.Resource("resource://test1", "Test Resource 1", - "First test resource", "text/plain", null); + McpSchema.Resource resource1 = McpSchema.Resource.builder() + .uri("resource://test1") + .name("Test Resource 1") + .description("First test resource") + .mimeType("text/plain") + .build(); - McpSchema.Resource resource2 = new McpSchema.Resource("resource://test2", "Test Resource 2", - "Second test resource", "application/json", null); + McpSchema.Resource resource2 = McpSchema.Resource.builder() + .uri("resource://test2") + .name("Test Resource 2") + .description("Second test resource") + .mimeType("application/json") + .build(); Map meta = Map.of("metaKey", "metaValue"); @@ -1274,7 +1287,7 @@ void testCallToolResultBuilder() throws Exception { @Test void testCallToolResultBuilderWithMultipleContents() throws Exception { McpSchema.TextContent textContent = new McpSchema.TextContent("Text result"); - McpSchema.ImageContent imageContent = new McpSchema.ImageContent(null, null, "base64data", "image/png"); + McpSchema.ImageContent imageContent = new McpSchema.ImageContent(null, "base64data", "image/png"); McpSchema.CallToolResult result = McpSchema.CallToolResult.builder() .addContent(textContent) @@ -1295,7 +1308,7 @@ void testCallToolResultBuilderWithMultipleContents() throws Exception { @Test void testCallToolResultBuilderWithContentList() throws Exception { McpSchema.TextContent textContent = new McpSchema.TextContent("Text result"); - McpSchema.ImageContent imageContent = new McpSchema.ImageContent(null, null, "base64data", "image/png"); + McpSchema.ImageContent imageContent = new McpSchema.ImageContent(null, "base64data", "image/png"); List contents = Arrays.asList(textContent, imageContent); McpSchema.CallToolResult result = McpSchema.CallToolResult.builder().content(contents).isError(true).build(); @@ -1326,27 +1339,6 @@ void testCallToolResultBuilderWithErrorResult() throws Exception { {"content":[{"type":"text","text":"Error: Operation failed"}],"isError":true}""")); } - @Test - void testCallToolResultStringConstructor() throws Exception { - // Test the existing string constructor alongside the builder - McpSchema.CallToolResult result1 = new McpSchema.CallToolResult("Simple result", false); - McpSchema.CallToolResult result2 = McpSchema.CallToolResult.builder() - .addTextContent("Simple result") - .isError(false) - .build(); - - String value1 = JSON_MAPPER.writeValueAsString(result1); - String value2 = JSON_MAPPER.writeValueAsString(result2); - - // Both should produce the same JSON - assertThat(value1).isEqualTo(value2); - assertThatJson(value1).when(Option.IGNORING_ARRAY_ORDER) - .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) - .isObject() - .isEqualTo(json(""" - {"content":[{"type":"text","text":"Simple result"}],"isError":false}""")); - } - // Sampling Tests @Test diff --git a/mcp/pom.xml b/mcp/pom.xml index 2db79f87b..937974228 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp jar @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-core - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT 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..049536e0d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.0-SNAPSHOT + 1.1.0-SNAPSHOT pom https://github.com/modelcontextprotocol/java-sdk @@ -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 @@ -322,6 +320,9 @@ true central + + mcp-parent,conformance-tests,client-jdk-http-client,client-spring-http-client,server-servlet + true