diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 8658ac785..ac967ff9f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a bug report to help us improve the project title: '' -labels: 'type: bug, status: waiting-for-triage' +labels: status/waiting for triage assignees: '' --- 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/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index aba7d39de..16ba64eef 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature request about: Suggest an idea for this project title: '' -labels: 'status: waiting-for-triage, type: feature' +labels: status/waiting for triage assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/miscellaneous.md b/.github/ISSUE_TEMPLATE/miscellaneous.md index d77c625c3..1db42e3b9 100644 --- a/.github/ISSUE_TEMPLATE/miscellaneous.md +++ b/.github/ISSUE_TEMPLATE/miscellaneous.md @@ -2,7 +2,7 @@ name: Miscellaneous about: Suggest an improvement for this project title: '' -labels: 'status: waiting-for-triage' +labels: status/waiting for triage assignees: '' --- 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/ci.yml b/.github/workflows/ci.yml index 7c73d9f38..0c79351a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: jobs: build: - name: Build branch + name: Build and Test runs-on: ubuntu-latest steps: - name: Checkout source code @@ -20,3 +20,20 @@ jobs: - name: Build run: mvn verify + + jackson2-tests: + name: Jackson 2 Integration Tests + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Jackson 2 Integration Tests + run: mvn -pl mcp-test -am -Pjackson2 test diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 000000000..efd06938f --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,104 @@ +name: Conformance Tests + +on: + pull_request: {} + push: + branches: [main] + workflow_dispatch: + +jobs: + server: + name: Server Conformance + runs-on: ubuntu-latest + 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 and start server + run: | + mvn clean install -DskipTests + mvn exec:java -pl conformance-tests/server-servlet -Dexec.mainClass="io.modelcontextprotocol.conformance.server.ConformanceServlet" & + timeout 30 bash -c 'until curl -s http://localhost:8080/mcp > /dev/null 2>&1; do sleep 0.5; done' + + - name: Run conformance tests + uses: modelcontextprotocol/conformance@v0.1.11 + with: + mode: server + url: http://localhost:8080/mcp + suite: active + expected-failures: ./conformance-tests/conformance-baseline.yml + + client: + name: Client Conformance + runs-on: ubuntu-latest + strategy: + matrix: + scenario: [initialize, tools_call, elicitation-sep1034-client-defaults, sse-retry] + 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.11 + with: + mode: client + 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/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..56b5a1207 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,54 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - 'docs/**' + - 'mkdocs.yml' + release: + types: + - published + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: 3.x + + - run: pip install mkdocs-material mike + + - name: Configure git user + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Deploy docs (push to main) + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + run: | + PROJECT_VERSION=$(mvn help:evaluate -Dexpression=project.version --quiet -DforceStdout) + if [[ "${PROJECT_VERSION}" == *-SNAPSHOT ]]; then + ALIAS="latest-snapshot" + else + ALIAS="latest" + fi + mike deploy --push --update-aliases "${PROJECT_VERSION}" "${ALIAS}" + mike set-default latest --push + + - name: Deploy versioned docs (release) + if: github.event_name == 'release' + run: | + VERSION=${GITHUB_REF_NAME} + mike deploy --push --update-aliases "${VERSION}" latest + mike set-default latest --push diff --git a/.github/workflows/maven-central-release.yml b/.github/workflows/maven-central-release.yml index c6c9d3ab6..8df337ec8 100644 --- a/.github/workflows/maven-central-release.yml +++ b/.github/workflows/maven-central-release.yml @@ -25,7 +25,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - + + - name: Jackson 2 Integration Tests + run: mvn -pl mcp-test -am -Pjackson2 test + - name: Build and Test run: mvn clean verify diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 5d9b4aa39..1a61d336c 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -32,6 +32,9 @@ jobs: - name: Generate Java docs run: mvn -Pjavadoc -B javadoc:aggregate + - name: Jackson 2 Integration Tests + run: mvn -pl mcp-test -am -Pjackson2 test + - name: Build with Maven and deploy to Sonatype snapshot repository env: MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }} diff --git a/.gitignore b/.gitignore index b80dac20d..1fc975c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ build/ out /.gradletasknamecache **/*.flattened-pom.xml +**/dependency-reduced-pom.xml ### IDE - Eclipse/STS ### .apt_generated @@ -56,6 +57,9 @@ node_modules/ package-lock.json package.json +### MkDocs ### +site/ + ### Other ### .antlr/ .profiler/ 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 7bda15006..34133a796 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Java Version](https://img.shields.io/badge/Java-17%2B-orange)](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html) -A set of projects that provide Java SDK integration for the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture). +A set of projects that provide Java SDK integration for the [Model Context Protocol](https://modelcontextprotocol.io/docs/concepts/architecture). This SDK enables Java applications to interact with AI models and tools through a standardized interface, supporting both synchronous and asynchronous communication patterns. ## 📚 Reference Documentation @@ -13,14 +13,17 @@ This SDK enables Java applications to interact with AI models and tools through #### MCP Java SDK documentation For comprehensive guides and SDK API documentation -- [Features](https://modelcontextprotocol.io/sdk/java/mcp-overview#features) - Overview the features provided by the Java MCP SDK -- [Architecture](https://modelcontextprotocol.io/sdk/java/mcp-overview#architecture) - Java MCP SDK architecture overview. -- [Java Dependencies / BOM](https://modelcontextprotocol.io/sdk/java/mcp-overview#dependencies) - Java dependencies and BOM. -- [Java MCP Client](https://modelcontextprotocol.io/sdk/java/mcp-client) - Learn how to use the MCP client to interact with MCP servers. -- [Java MCP Server](https://modelcontextprotocol.io/sdk/java/mcp-server) - Learn how to implement and configure a MCP servers. +- [Features](https://modelcontextprotocol.github.io/java-sdk/#features) - Overview the features provided by the Java MCP SDK +- [Architecture](https://modelcontextprotocol.github.io/java-sdk/#architecture) - Java MCP SDK architecture overview. +- [Java Dependencies / BOM](https://modelcontextprotocol.github.io/java-sdk/quickstart/#dependencies) - Java dependencies and BOM. +- [Java MCP Client](https://modelcontextprotocol.github.io/java-sdk/client/) - Learn how to use the MCP client to interact with MCP servers. +- [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. Bootstrap your AI applications with MCP support using [Spring Initializer](https://start.spring.io). +[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 @@ -83,11 +86,11 @@ The following sections explain what we chose, why it made sense, and how the cho ### 1. JSON Serialization -* **SDK Choice**: Jackson for JSON serialization and deserialization, behind an SDK abstraction (`mcp-json`) +* **SDK Choice**: Jackson for JSON serialization and deserialization, behind an SDK abstraction (package `io.modelcontextprotocol.json` in `mcp-core`) * **Why**: Jackson is widely adopted across the Java ecosystem, provides strong performance and a mature annotation model, and is familiar to the SDK team and many potential contributors. -* **How we expose it**: Public APIs use a zero-dependency abstraction (`mcp-json`). Jackson is shipped as the default implementation (`mcp-jackson2`), but alternatives can be plugged in. +* **How we expose it**: Public APIs use a bundled abstraction. Jackson is shipped as the default implementation (`mcp-json-jackson3`), but alternatives can be plugged in. * **How it fits the SDK**: This offers a pragmatic default while keeping flexibility for projects that prefer different JSON libraries. @@ -136,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. @@ -168,14 +171,26 @@ MCP supports both clients (applications consuming MCP servers) and servers (appl The SDK is organized into modules to separate concerns and allow adopters to bring in only what they need: * `mcp-bom` – Dependency versions -* `mcp-core` – Reference implementation (STDIO, JDK HttpClient, Servlet) -* `mcp-json` – JSON abstraction -* `mcp-jackson2` – Jackson implementation of JSON binding -* `mcp` – Convenience bundle (core + Jackson) +* `mcp-core` – Reference implementation (STDIO, JDK HttpClient, Servlet), JSON binding interface definitions +* `mcp-json-jackson2` – Jackson 2 implementation of JSON binding +* `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. +Implementations such as `mcp-json-jackson3`, depend on `mcp-core`, and therefore cannot be imported in `mcp-core` for tests. +Instead, all integration tests that need a JSON implementation are now in `mcp-test`, and use `jackson3` by default. +A `jackson2` maven profile allows to run integration tests with Jackson 2, like so: + + +```bash +./mvnw -pl mcp-test -am -Pjackson2 test +``` ### Future Directions 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/SECURITY.md b/SECURITY.md index 74e9880fd..502924200 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,21 @@ # Security Policy -Thank you for helping us keep the SDKs and systems they interact with secure. +Thank you for helping keep the Model Context Protocol and its ecosystem secure. ## Reporting Security Issues -This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model -Context Protocol project. +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. -The security of our systems and user data is Anthropic’s top priority. We appreciate the -work of security researchers acting in good faith in identifying and reporting potential -vulnerabilities. +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. -Our security program is managed on HackerOne and we ask that any validated vulnerability -in this functionality be reported through their -[submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). +## What to Include -## Vulnerability Disclosure Program +To help us triage and respond quickly, please include: -Our Vulnerability Program Guidelines are defined on our -[HackerOne program page](https://hackerone.com/anthropic-vdp). \ No newline at end of file +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) diff --git a/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 new file mode 100644 index 000000000..19e74330c --- /dev/null +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -0,0 +1,124 @@ +# MCP Java SDK Conformance Test Validation Results + +## Summary + +**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 + +### Passing (37/40) + +- **Lifecycle & Utilities (4/4):** initialize, ping, logging-set-level, completion-complete +- **Tools (11/11):** All scenarios including progress notifications ✨ +- **Elicitation (10/10):** SEP-1034 defaults (5 checks), SEP-1330 enums (5 checks) +- **Resources (4/6):** list, read-text, read-binary, templates-read +- **Prompts (4/4):** list, simple, with-args, embedded-resource, with-image +- **SSE Transport (2/2):** Multiple streams +- **Security (2/2):** Localhost validation passes, DNS rebinding protection + +### Failing (3/40) + +1. **resources-subscribe** - Not implemented in SDK +2. **resources-unsubscribe** - Not implemented in SDK + +## Client Test Results + +### Passing (3/4 scenarios, 9/10 checks) + +- **initialize (1/1):** Protocol negotiation, clientInfo, capabilities +- **tools_call (1/1):** Tool discovery and invocation +- **elicitation-sep1034-client-defaults (5/5):** Default values for string, integer, number, enum, boolean + +### Partially Passing (1/4 scenarios, 1/2 checks) + +- **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 + +### Server +```bash +# Start server +cd conformance-tests/server-servlet +../../mvnw compile exec:java -Dexec.mainClass="io.modelcontextprotocol.conformance.server.ConformanceServlet" + +# Run tests (in another terminal) +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --suite active +``` + +### Client +```bash +# Build +cd conformance-tests/client-jdk-http-client +../../mvnw clean package -DskipTests + +# Run all scenarios +for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-retry; do + npx @modelcontextprotocol/conformance client \ + --command "java -jar target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --scenario $scenario +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` +3. Implement CIMD +4. Implement scope step up diff --git a/conformance-tests/client-jdk-http-client/README.md b/conformance-tests/client-jdk-http-client/README.md new file mode 100644 index 000000000..44eccedf0 --- /dev/null +++ b/conformance-tests/client-jdk-http-client/README.md @@ -0,0 +1,135 @@ +# MCP Conformance Tests - JDK HTTP Client + +This module provides a conformance test client implementation for the Java MCP SDK using the JDK HTTP Client with Streamable HTTP transport. + +## 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 properly implements the MCP specification. + +## Architecture + +The client 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 +- **Server URL**: Passed as the last command-line argument + +## Supported Scenarios + +Currently implemented scenarios: + +- **initialize**: Tests the MCP client initialization handshake only + - ✅ Validates protocol version negotiation + - ✅ Validates clientInfo (name and version) + - ✅ Validates proper handling of server capabilities + - Does NOT call any tools or perform additional operations + +- **tools_call**: Tests tool discovery and invocation + - ✅ Initializes the client + - ✅ Lists available tools from the server + - ✅ Calls the `add_numbers` tool with test arguments (a=5, b=3) + - ✅ Validates the tool result + +- **elicitation-sep1034-client-defaults**: Tests client applies default values for omitted elicitation fields (SEP-1034) + - ✅ Initializes the client + - ✅ Lists available tools from the server + - ✅ Calls the `test_client_elicitation_defaults` tool + - ✅ Validates that the client properly applies default values from JSON schema to elicitation responses (5/5 checks pass) + +- **sse-retry**: Tests client respects SSE retry field timing and reconnects properly (SEP-1699) + - ⚠️ Initializes the client + - ⚠️ Lists available tools from the server + - ⚠️ Calls the `test_reconnection` tool which triggers SSE stream closure + - ✅ Client reconnects after stream closure (PASSING) + - ❌ Client does not respect retry timing (FAILING) + - ⚠️ Client does not send Last-Event-ID header (WARNING - SHOULD requirement) + +## Building + +Build the executable JAR: + +```bash +cd conformance-tests/client-jdk-http-client +../../mvnw clean package -DskipTests +``` + +This creates an executable JAR at: +``` +target/client-jdk-http-client-1.0.0-SNAPSHOT.jar +``` + +## Running Tests + +### Using the Conformance Framework + +Run a single scenario: + +```bash +npx @modelcontextprotocol/conformance client \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --scenario initialize + +npx @modelcontextprotocol/conformance client \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --scenario tools_call + +npx @modelcontextprotocol/conformance client \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --scenario elicitation-sep1034-client-defaults + +npx @modelcontextprotocol/conformance client \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --scenario sse-retry +``` + +Run with verbose output: + +```bash +npx @modelcontextprotocol/conformance client \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --scenario initialize \ + --verbose +``` + +### Manual Testing + +You can also run the client manually if you have a test server: + +```bash +export MCP_CONFORMANCE_SCENARIO=initialize +java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar http://localhost:3000/mcp +``` + +## Test Results + +The conformance framework generates test results showing: + +**Current Status (3/4 scenarios passing):** +- ✅ initialize: 1/1 checks passed +- ✅ tools_call: 1/1 checks passed +- ✅ elicitation-sep1034-client-defaults: 5/5 checks passed +- ⚠️ sse-retry: 1/2 checks passed, 1 warning + +Test result files are generated in `results/-/`: +- `checks.json`: Array of conformance check results with pass/fail status +- `stdout.txt`: Client stdout output +- `stderr.txt`: Client stderr output + +### Known Issue: SSE Retry Handling + +The `sse-retry` scenario currently fails because: +1. The client treats the SSE `retry:` field as invalid instead of parsing it +2. The client does not implement retry timing (reconnects immediately) +3. The client does not send the Last-Event-ID header on reconnection + +This is a known limitation in the `HttpClientStreamableHttpTransport` implementation. + +## Next Steps + +Future enhancements: + +- Fix SSE retry field handling (SEP-1699) to properly parse and respect retry timing +- Implement Last-Event-ID header on reconnection for resumability +- Add auth scenarios (currently excluded as per requirements) +- Implement a comprehensive "everything-client" pattern +- Add to CI/CD pipeline +- Create expected-failures baseline for known issues diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml new file mode 100644 index 000000000..f30361438 --- /dev/null +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + conformance-tests + 1.1.0-SNAPSHOT + + client-jdk-http-client + jar + MCP Conformance Tests - JDK HTTP Client + JDK 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 + + + + true + + + + + io.modelcontextprotocol.sdk + mcp + 1.1.0-SNAPSHOT + + + + + ch.qos.logback + logback-classic + ${logback.version} + runtime + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + io.modelcontextprotocol.conformance.client.ConformanceJdkClientMcpClient + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + diff --git a/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java b/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java new file mode 100644 index 000000000..570c4614e --- /dev/null +++ b/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java @@ -0,0 +1,286 @@ +package io.modelcontextprotocol.conformance.client; + +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; + +/** + * MCP Conformance Test Client - JDK 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. + * + *

+ * Usage: ConformanceJdkClientMcpClient <server-url> + * + * @see MCP Conformance + * Test Framework + */ +public class ConformanceJdkClientMcpClient { + + public static void main(String[] args) { + if (args.length == 0) { + System.err.println("Usage: ConformanceJdkClientMcpClient "); + System.err.println("The server URL must be provided as the last command-line argument."); + System.err.println("The MCP_CONFORMANCE_SCENARIO environment variable must be set."); + System.exit(1); + } + + String scenario = System.getenv("MCP_CONFORMANCE_SCENARIO"); + if (scenario == null || scenario.isEmpty()) { + System.err.println("Error: MCP_CONFORMANCE_SCENARIO environment variable is not set"); + System.exit(1); + } + + String serverUrl = args[args.length - 1]; + + try { + switch (scenario) { + case "initialize": + runInitializeScenario(serverUrl); + break; + case "tools_call": + runToolsCallScenario(serverUrl); + break; + case "elicitation-sep1034-client-defaults": + runElicitationDefaultsScenario(serverUrl); + break; + case "sse-retry": + runSSERetryScenario(serverUrl); + break; + default: + System.err.println("Unknown scenario: " + scenario); + System.err.println("Available scenarios:"); + System.err.println(" - initialize"); + System.err.println(" - tools_call"); + System.err.println(" - elicitation-sep1034-client-defaults"); + System.err.println(" - sse-retry"); + System.exit(1); + } + System.exit(0); + } + catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } + + /** + * Helper method to create and configure an MCP client with transport. + * @param serverUrl the URL of the MCP server + * @return configured McpSyncClient instance + */ + private static McpSyncClient createClient(String serverUrl) { + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl).build(); + + return McpClient.sync(transport) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + } + + /** + * Helper method to create and configure an MCP client with elicitation support. + * @param serverUrl the URL of the MCP server + * @return configured McpSyncClient instance with elicitation handler + */ + private static McpSyncClient createClientWithElicitation(String serverUrl) { + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl).build(); + + // Build client capabilities with elicitation support + var capabilities = McpSchema.ClientCapabilities.builder().elicitation().build(); + + return McpClient.sync(transport) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .capabilities(capabilities) + .elicitation(request -> { + // Apply default values from the schema to create the content + var content = new java.util.HashMap(); + var schema = request.requestedSchema(); + + if (schema != null && schema.containsKey("properties")) { + @SuppressWarnings("unchecked") + var properties = (java.util.Map) schema.get("properties"); + + // Apply defaults for each property + properties.forEach((key, propDef) -> { + @SuppressWarnings("unchecked") + var propMap = (java.util.Map) propDef; + if (propMap.containsKey("default")) { + content.put(key, propMap.get("default")); + } + }); + } + + // Return accept action with the defaults applied + return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, content, null); + }) + .build(); + } + + /** + * Initialize scenario: Tests MCP client initialization handshake. + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runInitializeScenario(String serverUrl) throws Exception { + McpSyncClient client = createClient(serverUrl); + + 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"); + } + } + + /** + * Tools call scenario: Tests tool listing and invocation functionality. + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runToolsCallScenario(String serverUrl) throws Exception { + McpSyncClient client = createClient(serverUrl); + + try { + // Initialize client + client.initialize(); + + System.out.println("Successfully connected to MCP server"); + + // List available tools + McpSchema.ListToolsResult toolsResult = client.listTools(); + System.out.println("Successfully listed tools"); + + // Call the add_numbers tool if it exists + if (toolsResult != null && toolsResult.tools() != null) { + for (McpSchema.Tool tool : toolsResult.tools()) { + if ("add_numbers".equals(tool.name())) { + // Call the add_numbers tool with test arguments + var arguments = new java.util.HashMap(); + arguments.put("a", 5); + arguments.put("b", 3); + + McpSchema.CallToolResult result = client + .callTool(new McpSchema.CallToolRequest("add_numbers", arguments)); + + System.out.println("Successfully called add_numbers tool"); + if (result != null && result.content() != null) { + System.out.println("Tool result: " + result.content()); + } + break; + } + } + } + } + finally { + // Close the client (which will close the transport) + client.close(); + System.out.println("Connection closed successfully"); + } + } + + /** + * Elicitation defaults scenario: Tests client applies default values for omitted + * elicitation fields (SEP-1034). + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runElicitationDefaultsScenario(String serverUrl) throws Exception { + McpSyncClient client = createClientWithElicitation(serverUrl); + + try { + // Initialize client + client.initialize(); + + System.out.println("Successfully connected to MCP server"); + + // List available tools + McpSchema.ListToolsResult toolsResult = client.listTools(); + System.out.println("Successfully listed tools"); + + // Call the test_client_elicitation_defaults tool if it exists + if (toolsResult != null && toolsResult.tools() != null) { + for (McpSchema.Tool tool : toolsResult.tools()) { + if ("test_client_elicitation_defaults".equals(tool.name())) { + // Call the tool which will trigger an elicitation request + var arguments = new java.util.HashMap(); + + McpSchema.CallToolResult result = client + .callTool(new McpSchema.CallToolRequest("test_client_elicitation_defaults", arguments)); + + System.out.println("Successfully called test_client_elicitation_defaults tool"); + if (result != null && result.content() != null) { + System.out.println("Tool result: " + result.content()); + } + break; + } + } + } + } + finally { + // Close the client (which will close the transport) + client.close(); + System.out.println("Connection closed successfully"); + } + } + + /** + * SSE retry scenario: Tests client respects SSE retry field timing and reconnects + * properly (SEP-1699). + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runSSERetryScenario(String serverUrl) throws Exception { + McpSyncClient client = createClient(serverUrl); + + try { + // Initialize client + client.initialize(); + + System.out.println("Successfully connected to MCP server"); + + // List available tools + McpSchema.ListToolsResult toolsResult = client.listTools(); + System.out.println("Successfully listed tools"); + + // Call the test_reconnection tool if it exists + if (toolsResult != null && toolsResult.tools() != null) { + for (McpSchema.Tool tool : toolsResult.tools()) { + if ("test_reconnection".equals(tool.name())) { + // Call the tool which will trigger SSE stream closure and + // reconnection + var arguments = new java.util.HashMap(); + + McpSchema.CallToolResult result = client + .callTool(new McpSchema.CallToolRequest("test_reconnection", arguments)); + + System.out.println("Successfully called test_reconnection tool"); + if (result != null && result.content() != null) { + System.out.println("Tool result: " + result.content()); + } + break; + } + } + } + } + finally { + // Close the client (which will close the transport) + client.close(); + System.out.println("Connection closed successfully"); + } + } + +} diff --git a/conformance-tests/client-jdk-http-client/src/main/resources/logback.xml b/conformance-tests/client-jdk-http-client/src/main/resources/logback.xml new file mode 100644 index 000000000..bb8e3795d --- /dev/null +++ b/conformance-tests/client-jdk-http-client/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + 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 new file mode 100644 index 000000000..4ab144063 --- /dev/null +++ b/conformance-tests/conformance-baseline.yml @@ -0,0 +1,18 @@ +# MCP Java SDK Conformance Test Baseline +# This file lists known failing scenarios that are expected to fail until fixed. +# See: https://github.com/modelcontextprotocol/conformance/blob/main/SDK_INTEGRATION.md + +server: + # Resource subscription not implemented in SDK + - resources-subscribe + - resources-unsubscribe + +client: + # SSE retry field handling not implemented + # - 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/mcp-json/pom.xml b/conformance-tests/pom.xml similarity index 52% rename from mcp-json/pom.xml rename to conformance-tests/pom.xml index 2cbcf3516..d1bef2a24 100644 --- a/mcp-json/pom.xml +++ b/conformance-tests/pom.xml @@ -6,34 +6,28 @@ io.modelcontextprotocol.sdk mcp-parent - 0.18.0-SNAPSHOT + 1.1.0-SNAPSHOT - mcp-json - jar - Java MCP SDK JSON Support - Java MCP SDK JSON Support API + conformance-tests + pom + MCP Conformance Tests + 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 - - - - org.apache.maven.plugins - maven-jar-plugin - - - - true - - - - - - - - + + true + + + + client-jdk-http-client + client-spring-http-client + server-servlet + + diff --git a/conformance-tests/server-servlet/README.md b/conformance-tests/server-servlet/README.md new file mode 100644 index 000000000..bd86636b6 --- /dev/null +++ b/conformance-tests/server-servlet/README.md @@ -0,0 +1,205 @@ +# MCP Conformance Tests - Servlet Server + +This module contains a comprehensive MCP (Model Context Protocol) server implementation for conformance testing using the servlet stack with an embedded Tomcat server and streamable HTTP transport. + +## Conformance Test Results + +**Status: 37 out of 40 tests passing (92.5%)** + +The server has been validated against the official [MCP conformance test suite](https://github.com/modelcontextprotocol/conformance). See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for detailed results. + +### What's Implemented + +✅ **Lifecycle & Utilities** (4/4) +- Server initialization, ping, logging, completion + +✅ **Tools** (11/11) +- Text, image, audio, embedded resources, mixed content +- Logging, error handling, sampling, elicitation +- Progress notifications + +✅ **Elicitation** (10/10) +- SEP-1034: Default values for all primitive types +- SEP-1330: All enum schema variants + +✅ **Resources** (4/6) +- List, read text/binary, templates +- ⚠️ Subscribe/unsubscribe (SDK limitation) + +✅ **Prompts** (4/4) +- Simple, parameterized, embedded resources, images + +✅ **SSE Transport** (2/2) +- Multiple streams support + +✅ **Security** (2/2) +- ✅ DNS rebinding protection + +## Features + +- Embedded Tomcat servlet container +- MCP server using HttpServletStreamableServerTransportProvider +- Comprehensive test coverage with 15+ tools +- Streamable HTTP transport with SSE on `/mcp` endpoint +- Support for all MCP content types (text, image, audio, resources) +- Advanced features: sampling, elicitation, progress (partial), completion + +## Running the Server + +To run the conformance server: + +```bash +cd conformance-tests/server-servlet +../../mvnw compile exec:java -Dexec.mainClass="io.modelcontextprotocol.conformance.server.ConformanceServlet" +``` + +Or from the root directory: + +```bash +./mvnw compile exec:java -pl conformance-tests/server-servlet -Dexec.mainClass="io.modelcontextprotocol.conformance.server.ConformanceServlet" +``` + +The server will start on port 8080 with the MCP endpoint at `/mcp`. + +## Running Conformance Tests + +Once the server is running, you can validate it against the official MCP conformance test suite using `npx`: + +### Run Full Active Test Suite + +```bash +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --suite active +``` + +### Run Specific Scenarios + +```bash +# Test tools +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --scenario tools-list --verbose + +# Test prompts +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --scenario prompts-list --verbose + +# Test resources +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --scenario resources-read-text --verbose + +# Test elicitation with defaults +npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --scenario elicitation-sep1034-defaults --verbose +``` + +### Available Test Suites + +- `active` (default) - All active/stable tests (30 scenarios) +- `all` - All tests including pending/experimental +- `pending` - Only pending/experimental tests + +### Common Scenarios + +**Lifecycle & Utilities:** +- `server-initialize` - Server initialization +- `ping` - Ping utility +- `logging-set-level` - Logging configuration +- `completion-complete` - Argument completion + +**Tools:** +- `tools-list` - List available tools +- `tools-call-simple-text` - Simple text response +- `tools-call-image` - Image content +- `tools-call-audio` - Audio content +- `tools-call-with-logging` - Logging during execution +- `tools-call-with-progress` - Progress notifications +- `tools-call-sampling` - LLM sampling +- `tools-call-elicitation` - User input requests + +**Resources:** +- `resources-list` - List resources +- `resources-read-text` - Read text resource +- `resources-read-binary` - Read binary resource +- `resources-templates-read` - Resource templates +- `resources-subscribe` - Subscribe to resource updates +- `resources-unsubscribe` - Unsubscribe from updates + +**Prompts:** +- `prompts-list` - List prompts +- `prompts-get-simple` - Simple prompt +- `prompts-get-with-args` - Parameterized prompt +- `prompts-get-embedded-resource` - Prompt with resource +- `prompts-get-with-image` - Prompt with image + +**Elicitation:** +- `elicitation-sep1034-defaults` - Default values (SEP-1034) +- `elicitation-sep1330-enums` - Enum schemas (SEP-1330) + +## Testing with curl + +You can also test the endpoint manually: + +```bash +# Check endpoint (will show SSE requirement) +curl -X GET http://localhost:8080/mcp + +# Initialize session with proper headers +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -H "mcp-session-id: test-session-123" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}' +``` + +## Architecture + +- **Transport**: HttpServletStreamableServerTransportProvider (streamable HTTP with SSE) +- **Container**: Embedded Apache Tomcat +- **Protocol**: Streamable HTTP with Server-Sent Events +- **Port**: 8080 (default) +- **Endpoint**: `/mcp` +- **Request Timeout**: 30 seconds + +## Implemented Tools + +### Content Type Tools +- `test_simple_text` - Returns simple text content +- `test_image_content` - Returns a minimal PNG image (1x1 red pixel) +- `test_audio_content` - Returns a minimal WAV audio file +- `test_embedded_resource` - Returns embedded resource content +- `test_multiple_content_types` - Returns mixed text, image, and resource content + +### Behavior Tools +- `test_tool_with_logging` - Sends log notifications during execution +- `test_error_handling` - Intentionally returns an error for testing +- `test_tool_with_progress` - Reports progress notifications (⚠️ SDK issue) + +### Interactive Tools +- `test_sampling` - Requests LLM sampling from client +- `test_elicitation` - Requests user input from client +- `test_elicitation_sep1034_defaults` - Elicitation with default values (SEP-1034) +- `test_elicitation_sep1330_enums` - Elicitation with enum schemas (SEP-1330) + +## Implemented Prompts + +- `test_simple_prompt` - Simple prompt without arguments +- `test_prompt_with_arguments` - Prompt with required arguments (arg1, arg2) +- `test_prompt_with_embedded_resource` - Prompt with embedded resource content +- `test_prompt_with_image` - Prompt with image content + +## Implemented Resources + +- `test://static-text` - Static text resource +- `test://static-binary` - Static binary resource (PNG image) +- `test://watched-resource` - Resource that can be subscribed to +- `test://template/{id}/data` - Resource template with parameter substitution + +## Known Limitations + +See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for details on: + +1. **Resource Subscriptions** - Not implemented in Java SDK +2. **DNS Rebinding Protection** - Missing Host/Origin validation + +These are SDK-level limitations that require fixes in the core framework. + +## References + +- [MCP Specification](https://modelcontextprotocol.io/specification/) +- [MCP Conformance Tests](https://github.com/modelcontextprotocol/conformance) +- [SDK Integration Guide](https://github.com/modelcontextprotocol/conformance/blob/main/SDK_INTEGRATION.md) diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml new file mode 100644 index 000000000..68da42158 --- /dev/null +++ b/conformance-tests/server-servlet/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + conformance-tests + 1.1.0-SNAPSHOT + + server-servlet + jar + MCP Conformance Tests - Servlet Server + Servlet Server 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 + + + + true + + + + + io.modelcontextprotocol.sdk + mcp + 1.1.0-SNAPSHOT + + + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet.version} + provided + + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + io.modelcontextprotocol.conformance.server.ConformanceServlet + + + + + + \ No newline at end of file diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java new file mode 100644 index 000000000..3d162a5de --- /dev/null +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -0,0 +1,596 @@ +package io.modelcontextprotocol.conformance.server; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema.AudioContent; +import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.CompleteResult; +import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; +import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import io.modelcontextprotocol.spec.McpSchema.EmbeddedResource; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.ImageContent; +import io.modelcontextprotocol.spec.McpSchema.JsonSchema; +import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; +import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; +import io.modelcontextprotocol.spec.McpSchema.ProgressNotification; +import io.modelcontextprotocol.spec.McpSchema.Prompt; +import io.modelcontextprotocol.spec.McpSchema.PromptArgument; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.PromptReference; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.spec.McpSchema.Resource; +import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.SamplingMessage; +import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ConformanceServlet { + + private static final Logger logger = LoggerFactory.getLogger(ConformanceServlet.class); + + private static final int PORT = 8080; + + private static final String MCP_ENDPOINT = "/mcp"; + + private static final JsonSchema EMPTY_JSON_SCHEMA = new JsonSchema("object", Collections.emptyMap(), null, null, + null, null); + + // Minimal 1x1 red pixel PNG (base64 encoded) + private static final String RED_PIXEL_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + + // Minimal WAV file (base64 encoded) - 1 sample at 8kHz + private static final String MINIMAL_WAV = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQAAAAA="; + + public static void main(String[] args) throws Exception { + logger.info("Starting MCP Conformance Tests - Servlet Server"); + + HttpServletStreamableServerTransportProvider transportProvider = HttpServletStreamableServerTransportProvider + .builder() + .mcpEndpoint(MCP_ENDPOINT) + .keepAliveInterval(Duration.ofSeconds(30)) + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) + .build(); + + // Build server with all conformance test features + var mcpServer = McpServer.sync(transportProvider) + .serverInfo("mcp-conformance-server", "1.0.0") + .capabilities(ServerCapabilities.builder() + .completions() + .resources(true, false) + .tools(false) + .prompts(false) + .build()) + .tools(createToolSpecs()) + .prompts(createPromptSpecs()) + .resources(createResourceSpecs()) + .resourceTemplates(createResourceTemplateSpecs()) + .completions(createCompletionSpecs()) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + + // Set up embedded Tomcat + Tomcat tomcat = createEmbeddedTomcat(transportProvider); + + try { + tomcat.start(); + logger.info("Conformance MCP Servlet Server started on port {} with endpoint {}", PORT, MCP_ENDPOINT); + logger.info("Server URL: http://localhost:{}{}", PORT, MCP_ENDPOINT); + + // Keep the server running + tomcat.getServer().await(); + } + catch (LifecycleException e) { + logger.error("Failed to start Tomcat server", e); + throw e; + } + finally { + logger.info("Shutting down MCP server..."); + mcpServer.closeGracefully(); + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + logger.error("Error during Tomcat shutdown", e); + } + } + } + + private static Tomcat createEmbeddedTomcat(HttpServletStreamableServerTransportProvider transportProvider) { + Tomcat tomcat = new Tomcat(); + tomcat.setPort(PORT); + + String baseDir = System.getProperty("java.io.tmpdir"); + tomcat.setBaseDir(baseDir); + + Context context = tomcat.addContext("", baseDir); + + // Add the MCP servlet to Tomcat + org.apache.catalina.Wrapper wrapper = context.createWrapper(); + wrapper.setName("mcpServlet"); + wrapper.setServlet(transportProvider); + wrapper.setLoadOnStartup(1); + wrapper.setAsyncSupported(true); + context.addChild(wrapper); + context.addServletMappingDecoded("/*", "mcpServlet"); + + var connector = tomcat.getConnector(); + connector.setAsyncTimeout(30000); + return tomcat; + } + + private static List createToolSpecs() { + return List.of( + // test_simple_text - Returns simple text content + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_simple_text") + .description("Returns simple text content for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_simple_text' called"); + return CallToolResult.builder() + .content(List.of(new TextContent("This is a simple text response for testing."))) + .isError(false) + .build(); + }) + .build(), + + // test_image_content - Returns image content + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_image_content") + .description("Returns image content for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_image_content' called"); + return CallToolResult.builder() + .content(List.of(new ImageContent(null, RED_PIXEL_PNG, "image/png"))) + .isError(false) + .build(); + }) + .build(), + + // test_audio_content - Returns audio content + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_audio_content") + .description("Returns audio content for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_audio_content' called"); + return CallToolResult.builder() + .content(List.of(new AudioContent(null, MINIMAL_WAV, "audio/wav"))) + .isError(false) + .build(); + }) + .build(), + + // test_embedded_resource - Returns embedded resource content + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_embedded_resource") + .description("Returns embedded resource content for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_embedded_resource' called"); + TextResourceContents resourceContents = new TextResourceContents("test://embedded-resource", + "text/plain", "This is an embedded resource content."); + EmbeddedResource embeddedResource = new EmbeddedResource(null, resourceContents); + return CallToolResult.builder().content(List.of(embeddedResource)).isError(false).build(); + }) + .build(), + + // test_multiple_content_types - Returns multiple content types + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_multiple_content_types") + .description("Returns multiple content types for testing") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_multiple_content_types' called"); + TextResourceContents resourceContents = new TextResourceContents( + "test://mixed-content-resource", "application/json", + "{\"test\":\"data\",\"value\":123}"); + EmbeddedResource embeddedResource = new EmbeddedResource(null, resourceContents); + return CallToolResult.builder() + .content(List.of(new TextContent("Multiple content types test:"), + new ImageContent(null, RED_PIXEL_PNG, "image/png"), embeddedResource)) + .isError(false) + .build(); + }) + .build(), + + // test_tool_with_logging - Tool that sends log messages during execution + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_tool_with_logging") + .description("Tool that sends log messages during execution") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_tool_with_logging' called"); + // Send log notifications + exchange.loggingNotification(LoggingMessageNotification.builder() + .level(LoggingLevel.INFO) + .data("Tool execution started") + .build()); + exchange.loggingNotification(LoggingMessageNotification.builder() + .level(LoggingLevel.INFO) + .data("Tool processing data") + .build()); + exchange.loggingNotification(LoggingMessageNotification.builder() + .level(LoggingLevel.INFO) + .data("Tool execution completed") + .build()); + return CallToolResult.builder() + .content(List.of(new TextContent("Tool execution completed with logging"))) + .isError(false) + .build(); + }) + .build(), + + // test_error_handling - Tool that always returns an error + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_error_handling") + .description("Tool that returns an error for testing error handling") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_error_handling' called"); + return CallToolResult.builder() + .content(List.of(new TextContent("This tool intentionally returns an error for testing"))) + .isError(true) + .build(); + }) + .build(), + + // test_tool_with_progress - Tool that reports progress + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_tool_with_progress") + .description("Tool that reports progress notifications") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_tool_with_progress' called"); + Object progressToken = request.meta().get("progressToken"); + if (progressToken != null) { + // Send progress notifications sequentially + exchange.progressNotification(new ProgressNotification(progressToken, 0.0, 100.0, null)); + // try { + // Thread.sleep(50); + // } + // catch (InterruptedException e) { + // Thread.currentThread().interrupt(); + // } + exchange.progressNotification(new ProgressNotification(progressToken, 50.0, 100.0, null)); + // try { + // Thread.sleep(50); + // } + // catch (InterruptedException e) { + // Thread.currentThread().interrupt(); + // } + exchange.progressNotification(new ProgressNotification(progressToken, 100.0, 100.0, null)); + return CallToolResult.builder() + .content(List.of(new TextContent("Tool execution completed with progress"))) + .isError(false) + .build(); + } + else { + // No progress token, just execute with delays + // try { + // Thread.sleep(100); + // } + // catch (InterruptedException e) { + // Thread.currentThread().interrupt(); + // } + return CallToolResult.builder() + .content(List.of(new TextContent("Tool execution completed without progress"))) + .isError(false) + .build(); + } + }) + .build(), + + // test_sampling - Tool that requests LLM sampling from client + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_sampling") + .description("Tool that requests LLM sampling from client") + .inputSchema(new JsonSchema("object", + Map.of("prompt", + Map.of("type", "string", "description", "The prompt to send to the LLM")), + List.of("prompt"), null, null, null)) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_sampling' called"); + String prompt = (String) request.arguments().get("prompt"); + + // Request sampling from client + CreateMessageRequest samplingRequest = CreateMessageRequest.builder() + .messages(List.of(new SamplingMessage(Role.USER, new TextContent(prompt)))) + .maxTokens(100) + .build(); + + CreateMessageResult response = exchange.createMessage(samplingRequest); + String responseText = "LLM response: " + ((TextContent) response.content()).text(); + return CallToolResult.builder() + .content(List.of(new TextContent(responseText))) + .isError(false) + .build(); + }) + .build(), + + // test_elicitation - Tool that requests user input from client + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_elicitation") + .description("Tool that requests user input from client") + .inputSchema(new JsonSchema("object", + Map.of("message", + Map.of("type", "string", "description", "The message to show the user")), + List.of("message"), null, null, null)) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_elicitation' called"); + String message = (String) request.arguments().get("message"); + + // Request elicitation from client + Map requestedSchema = Map.of("type", "object", "properties", + Map.of("username", Map.of("type", "string", "description", "User's response"), "email", + Map.of("type", "string", "description", "User's email address")), + "required", List.of("username", "email")); + + ElicitRequest elicitRequest = new ElicitRequest(message, requestedSchema); + + ElicitResult response = exchange.createElicitation(elicitRequest); + String responseText = "User response: action=" + response.action() + ", content=" + + response.content(); + return CallToolResult.builder() + .content(List.of(new TextContent(responseText))) + .isError(false) + .build(); + }) + .build(), + + // test_elicitation_sep1034_defaults - Tool with default values for all + // primitive types + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_elicitation_sep1034_defaults") + .description("Tool that requests elicitation with default values for all primitive types") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_elicitation_sep1034_defaults' called"); + + // Create schema with default values for all primitive types + Map requestedSchema = Map.of("type", "object", "properties", + Map.of("name", Map.of("type", "string", "default", "John Doe"), "age", + Map.of("type", "integer", "default", 30), "score", + Map.of("type", "number", "default", 95.5), "status", + Map.of("type", "string", "enum", List.of("active", "inactive", "pending"), + "default", "active"), + "verified", Map.of("type", "boolean", "default", true)), + "required", List.of("name", "age", "score", "status", "verified")); + + ElicitRequest elicitRequest = new ElicitRequest("Please provide your information with defaults", + requestedSchema); + + ElicitResult response = exchange.createElicitation(elicitRequest); + String responseText = "Elicitation completed: action=" + response.action() + ", content=" + + response.content(); + return CallToolResult.builder() + .content(List.of(new TextContent(responseText))) + .isError(false) + .build(); + }) + .build(), + + // test_elicitation_sep1330_enums - Tool with enum schema improvements + McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder() + .name("test_elicitation_sep1330_enums") + .description("Tool that requests elicitation with enum schema improvements") + .inputSchema(EMPTY_JSON_SCHEMA) + .build()) + .callHandler((exchange, request) -> { + logger.info("Tool 'test_elicitation_sep1330_enums' called"); + + // Create schema with all 5 enum variants + Map requestedSchema = Map.of("type", "object", "properties", Map.of( + // 1. Untitled single-select + "untitledSingle", + Map.of("type", "string", "enum", List.of("option1", "option2", "option3")), + // 2. Titled single-select using oneOf with const/title + "titledSingle", + Map.of("type", "string", "oneOf", + List.of(Map.of("const", "value1", "title", "First Option"), + Map.of("const", "value2", "title", "Second Option"), + Map.of("const", "value3", "title", "Third Option"))), + // 3. Legacy titled using enumNames (deprecated) + "legacyEnum", + Map.of("type", "string", "enum", List.of("opt1", "opt2", "opt3"), "enumNames", + List.of("Option One", "Option Two", "Option Three")), + // 4. Untitled multi-select + "untitledMulti", + Map.of("type", "array", "items", + Map.of("type", "string", "enum", List.of("option1", "option2", "option3"))), + // 5. Titled multi-select using items.anyOf with + // const/title + "titledMulti", + Map.of("type", "array", "items", + Map.of("anyOf", + List.of(Map.of("const", "value1", "title", "First Choice"), + Map.of("const", "value2", "title", "Second Choice"), + Map.of("const", "value3", "title", "Third Choice"))))), + "required", List.of("untitledSingle", "titledSingle", "legacyEnum", "untitledMulti", + "titledMulti")); + + ElicitRequest elicitRequest = new ElicitRequest("Select your preferences", requestedSchema); + + ElicitResult response = exchange.createElicitation(elicitRequest); + String responseText = "Elicitation completed: action=" + response.action() + ", content=" + + response.content(); + return CallToolResult.builder() + .content(List.of(new TextContent(responseText))) + .isError(false) + .build(); + }) + .build()); + } + + private static List createPromptSpecs() { + return List.of( + // test_simple_prompt - Simple prompt without arguments + new McpServerFeatures.SyncPromptSpecification( + new Prompt("test_simple_prompt", null, "A simple prompt for testing", List.of()), + (exchange, request) -> { + logger.info("Prompt 'test_simple_prompt' requested"); + return new GetPromptResult(null, List.of(new PromptMessage(Role.USER, + new TextContent("This is a simple prompt for testing.")))); + }), + + // test_prompt_with_arguments - Prompt with arguments + new McpServerFeatures.SyncPromptSpecification( + new Prompt("test_prompt_with_arguments", null, "A prompt with arguments for testing", + List.of(new PromptArgument("arg1", "First test argument", true), + new PromptArgument("arg2", "Second test argument", true))), + (exchange, request) -> { + logger.info("Prompt 'test_prompt_with_arguments' requested"); + String arg1 = (String) request.arguments().get("arg1"); + String arg2 = (String) request.arguments().get("arg2"); + String text = String.format("Prompt with arguments: arg1='%s', arg2='%s'", arg1, arg2); + return new GetPromptResult(null, + List.of(new PromptMessage(Role.USER, new TextContent(text)))); + }), + + // test_prompt_with_embedded_resource - Prompt with embedded resource + new McpServerFeatures.SyncPromptSpecification( + new Prompt("test_prompt_with_embedded_resource", null, + "A prompt with embedded resource for testing", + List.of(new PromptArgument("resourceUri", "URI of the resource to embed", true))), + (exchange, request) -> { + logger.info("Prompt 'test_prompt_with_embedded_resource' requested"); + String resourceUri = (String) request.arguments().get("resourceUri"); + TextResourceContents resourceContents = new TextResourceContents(resourceUri, "text/plain", + "Embedded resource content for testing."); + EmbeddedResource embeddedResource = new EmbeddedResource(null, resourceContents); + return new GetPromptResult(null, + List.of(new PromptMessage(Role.USER, embeddedResource), new PromptMessage(Role.USER, + new TextContent("Please process the embedded resource above.")))); + }), + + // test_prompt_with_image - Prompt with image content + new McpServerFeatures.SyncPromptSpecification(new Prompt("test_prompt_with_image", null, + "A prompt with image content for testing", List.of()), (exchange, request) -> { + logger.info("Prompt 'test_prompt_with_image' requested"); + return new GetPromptResult(null, List.of( + new PromptMessage(Role.USER, new ImageContent(null, RED_PIXEL_PNG, "image/png")), + new PromptMessage(Role.USER, new TextContent("Please analyze the image above.")))); + })); + } + + private static List createResourceSpecs() { + return List.of( + // test://static-text - Static text resource + new McpServerFeatures.SyncResourceSpecification(Resource.builder() + .uri("test://static-text") + .name("Static Text Resource") + .description("A static text resource for testing") + .mimeType("text/plain") + .build(), (exchange, request) -> { + logger.info("Resource 'test://static-text' requested"); + return new ReadResourceResult(List.of(new TextResourceContents("test://static-text", + "text/plain", "This is the content of the static text resource."))); + }), + + // test://static-binary - Static binary resource (image) + new McpServerFeatures.SyncResourceSpecification(Resource.builder() + .uri("test://static-binary") + .name("Static Binary Resource") + .description("A static binary resource for testing") + .mimeType("image/png") + .build(), (exchange, request) -> { + logger.info("Resource 'test://static-binary' requested"); + return new ReadResourceResult( + List.of(new BlobResourceContents("test://static-binary", "image/png", RED_PIXEL_PNG))); + }), + + // test://watched-resource - Resource that can be subscribed to + new McpServerFeatures.SyncResourceSpecification(Resource.builder() + .uri("test://watched-resource") + .name("Watched Resource") + .description("A resource that can be subscribed to for updates") + .mimeType("text/plain") + .build(), (exchange, request) -> { + logger.info("Resource 'test://watched-resource' requested"); + return new ReadResourceResult(List.of(new TextResourceContents("test://watched-resource", + "text/plain", "This is a watched resource content."))); + })); + } + + private static List createResourceTemplateSpecs() { + return List.of( + // test://template/{id}/data - Resource template with parameter + // substitution + new McpServerFeatures.SyncResourceTemplateSpecification(ResourceTemplate.builder() + .uriTemplate("test://template/{id}/data") + .name("Template Resource") + .description("A resource template for testing parameter substitution") + .mimeType("application/json") + .build(), (exchange, request) -> { + logger.info("Resource template 'test://template/{{id}}/data' requested for URI: {}", + request.uri()); + // Extract id from URI + String uri = request.uri(); + String id = uri.replaceAll("test://template/(.+)/data", "$1"); + String jsonContent = String + .format("{\"id\":\"%s\",\"templateTest\":true,\"data\":\"Data for ID: %s\"}", id, id); + return new ReadResourceResult( + List.of(new TextResourceContents(uri, "application/json", jsonContent))); + })); + } + + private static List createCompletionSpecs() { + return List.of( + // Completion for test_prompt_with_arguments + new McpServerFeatures.SyncCompletionSpecification(new PromptReference("test_prompt_with_arguments"), + (exchange, request) -> { + logger.info("Completion requested for prompt 'test_prompt_with_arguments', argument: {}", + request.argument().name()); + // Return minimal completion with required fields + return new CompleteResult(new CompleteResult.CompleteCompletion(List.of(), 0, false)); + })); + } + +} diff --git a/conformance-tests/server-servlet/src/main/resources/logback.xml b/conformance-tests/server-servlet/src/main/resources/logback.xml new file mode 100644 index 000000000..af69ac902 --- /dev/null +++ b/conformance-tests/server-servlet/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/docs/blog/.authors.yml b/docs/blog/.authors.yml new file mode 100644 index 000000000..7b255c403 --- /dev/null +++ b/docs/blog/.authors.yml @@ -0,0 +1,5 @@ +authors: + mcp-team: + name: MCP Java SDK Team + description: Maintainers of the MCP Java SDK + avatar: https://github.com/modelcontextprotocol.png diff --git a/docs/blog/index.md b/docs/blog/index.md new file mode 100644 index 000000000..e61459078 --- /dev/null +++ b/docs/blog/index.md @@ -0,0 +1 @@ +# News diff --git a/docs/blog/posts/mcp-server-performance-benchmark.md b/docs/blog/posts/mcp-server-performance-benchmark.md new file mode 100644 index 000000000..a08b807b6 --- /dev/null +++ b/docs/blog/posts/mcp-server-performance-benchmark.md @@ -0,0 +1,72 @@ +--- +date: 2026-02-15 +authors: + - mcp-team +categories: + - Performance + - Benchmarks +--- + +# Java Leads MCP Server Performance Benchmarks with Sub-Millisecond Latency + +A comprehensive independent benchmark of MCP server implementations across four major languages puts Java at the top of the performance charts — delivering sub-millisecond latency, the highest throughput, and the best CPU efficiency of all tested platforms. + + + +## The Benchmark + +[TM Dev Lab](https://www.tmdevlab.com/mcp-server-performance-benchmark.html) published a rigorous performance comparison of MCP server implementations spanning **3.9 million total requests** across three independent test rounds. The benchmark evaluated four implementations under identical conditions: + +- **Java** — Spring Boot 4.0.0 + Spring AI 2.0.0-M2 on Java 21 +- **Go** — Official MCP SDK v1.2.0 +- **Node.js** — @modelcontextprotocol/sdk v1.26.0 +- **Python** — FastMCP 2.12.0+ with FastAPI 0.109.0+ + +Each server was tested with 50 concurrent virtual users over 5-minute sustained runs in Docker containers (1-core CPU, 1GB memory) on Ubuntu 24.04.3 LTS. Four standardized benchmark tools measured CPU-intensive, I/O-intensive, data transformation, and latency-handling scenarios — all with a **0% error rate** across every implementation. + +## Java's Performance Highlights + +The results speak for themselves: + +| Server | Avg Latency | Throughput (RPS) | CPU Efficiency (RPS/CPU%) | +|------------|-------------|------------------|---------------------------| +| **Java** | **0.835 ms** | **1,624** | **57.2** | +| Go | 0.855 ms | 1,624 | 50.4 | +| Node.js | 10.66 ms | 559 | 5.7 | +| Python | 26.45 ms | 292 | 3.2 | + +```mermaid +--- +config: + xyChart: + width: 700 + height: 400 + themeVariables: + xyChart: + backgroundColor: transparent +--- +xychart-beta + title "Average Latency Comparison (milliseconds)" + x-axis [Java, Go, "Node.js", Python] + y-axis "Latency (ms)" 0 --> 30 + bar [0.84, 0.86, 10.66, 26.45] +``` + +Java achieved the **lowest average latency** at 0.835 ms — edging out Go's 0.855 ms — while matching its throughput at 1,624 requests per second. Where Java truly stands out is **CPU efficiency**: at 57.2 RPS per CPU%, it extracts more performance per compute cycle than any other implementation, including Go (50.4). + +In CPU-bound workloads like Fibonacci calculation, Java excelled with a **0.369 ms** response time, showcasing the JVM's highly optimized just-in-time compilation. + +## A Clear Performance Tier + +The benchmark reveals two distinct performance tiers: + +- **High-performance tier**: Java and Go deliver sub-millisecond latencies and 1,600+ RPS +- **Standard tier**: Node.js (12x slower) and Python (31x slower) trail significantly + +Java's throughput is **2.9x higher than Node.js** and **5.6x higher than Python**. For latency-sensitive MCP deployments, the difference is even more pronounced — Java responds **12.8x faster than Node.js** and **31.7x faster than Python**. + +## What This Means for MCP Developers + +For teams building production MCP servers that need to handle high concurrency and low-latency tool interactions, Java with Spring Boot and Spring AI provides a battle-tested, high-performance foundation. The JVM's mature ecosystem, strong typing, and proven scalability make it an excellent choice for enterprise MCP deployments where performance and reliability are paramount. + +The full benchmark details, methodology, and raw data are available at [TM Dev Lab](https://www.tmdevlab.com/mcp-server-performance-benchmark.html). diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 000000000..6a99928c5 --- /dev/null +++ b/docs/client.md @@ -0,0 +1,439 @@ +--- +title: MCP Client +description: Learn how to use the Model Context Protocol (MCP) client to interact with MCP servers +--- + +# MCP Client + +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, handling: + +- Protocol version negotiation to ensure compatibility with servers +- Capability negotiation to determine available features +- Message transport and JSON-RPC communication +- Tool discovery and execution with optional schema validation +- Resource access and management +- Prompt system interactions +- Optional features like roots management, sampling, and elicitation support +- Progress tracking for long-running operations + +!!! tip + The core `io.modelcontextprotocol.sdk:mcp` module provides STDIO, SSE, and Streamable HTTP client transport implementations without requiring external web frameworks. + + 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. + +=== "Sync API" + + ```java + // Create a sync client with custom configuration + McpSyncClient client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(10)) + .capabilities(ClientCapabilities.builder() + .roots(true) // Enable roots capability + .sampling() // Enable sampling capability + .elicitation() // Enable elicitation capability + .build()) + .sampling(request -> new CreateMessageResult(response)) + .elicitation(request -> new ElicitResult(ElicitResult.Action.ACCEPT, content)) + .build(); + + // Initialize connection + client.initialize(); + + // List available tools + ListToolsResult tools = client.listTools(); + + // Call a tool + CallToolResult result = client.callTool( + new CallToolRequest("calculator", + Map.of("operation", "add", "a", 2, "b", 3)) + ); + + // List and read resources + ListResourcesResult resources = client.listResources(); + ReadResourceResult resource = client.readResource( + new ReadResourceRequest("resource://uri") + ); + + // List and use prompts + ListPromptsResult prompts = client.listPrompts(); + GetPromptResult prompt = client.getPrompt( + new GetPromptRequest("greeting", Map.of("name", "Spring")) + ); + + // Add/remove roots + client.addRoot(new Root("file:///path", "description")); + client.removeRoot("file:///path"); + + // Close client + client.closeGracefully(); + ``` + +=== "Async API" + + ```java + // Create an async client with custom configuration + McpAsyncClient client = McpClient.async(transport) + .requestTimeout(Duration.ofSeconds(10)) + .capabilities(ClientCapabilities.builder() + .roots(true) // Enable roots capability + .sampling() // Enable sampling capability + .elicitation() // Enable elicitation capability + .build()) + .sampling(request -> Mono.just(new CreateMessageResult(response))) + .elicitation(request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, content))) + .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> { + logger.info("Tools updated: {}", tools); + })) + .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> { + logger.info("Resources updated: {}", resources); + })) + .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> { + logger.info("Prompts updated: {}", prompts); + })) + .progressConsumer(progress -> Mono.fromRunnable(() -> { + logger.info("Progress: {}", progress); + })) + .build(); + + // Initialize connection and use features + client.initialize() + .flatMap(initResult -> client.listTools()) + .flatMap(tools -> { + return client.callTool(new CallToolRequest( + "calculator", + Map.of("operation", "add", "a", 2, "b", 3) + )); + }) + .flatMap(result -> { + return client.listResources() + .flatMap(resources -> + client.readResource(new ReadResourceRequest("resource://uri")) + ); + }) + .flatMap(resource -> { + return client.listPrompts() + .flatMap(prompts -> + client.getPrompt(new GetPromptRequest( + "greeting", + Map.of("name", "Spring") + )) + ); + }) + .flatMap(prompt -> { + return client.addRoot(new Root("file:///path", "description")) + .then(client.removeRoot("file:///path")); + }) + .doFinally(signalType -> { + client.closeGracefully().subscribe(); + }) + .subscribe(); + ``` + +## Client Transport + +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 + +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); +``` + +### Streamable HTTP + +=== "Streamable HttpClient" + + Creates a Streamable HTTP client transport for efficient bidirectional communication. Included in the core `mcp` module: + + ```java + McpTransport transport = HttpClientStreamableHttpTransport + .builder("http://your-mcp-server") + .endpoint("/mcp") + .build(); + ``` + + The Streamable HTTP transport supports: + + - Resumable streams for connection recovery + - Configurable connect timeout + - Custom HTTP request customization + - Multiple protocol version negotiation + +=== "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`): + + ```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() + .baseUrl("http://your-mcp-server"); + McpTransport transport = new WebFluxSseClientTransport(webClientBuilder); + ``` + + +## Client Capabilities + +The client can be configured with various capabilities: + +```java +var capabilities = ClientCapabilities.builder() + .roots(true) // Enable filesystem roots support with list changes notifications + .sampling() // Enable LLM sampling support + .elicitation() // Enable elicitation support (form and URL modes) + .build(); +``` + +You can also configure elicitation with specific mode support: + +```java +var capabilities = ClientCapabilities.builder() + .elicitation(true, false) // Enable form-based elicitation, disable URL-based + .build(); +``` + +### Roots Support + +Roots define the boundaries of where servers can operate within the filesystem: + +```java +// Add a root dynamically +client.addRoot(new Root("file:///path", "description")); + +// Remove a root +client.removeRoot("file:///path"); + +// Notify server of roots changes +client.rootsListChangedNotification(); +``` + +The roots capability allows servers to: + +- Request the list of accessible filesystem roots +- Receive notifications when the roots list changes +- Understand which directories and files they have access to + +### Sampling Support + +Sampling enables servers to request LLM interactions ("completions" or "generations") through the client: + +```java +// Configure sampling handler +Function samplingHandler = request -> { + // Sampling implementation that interfaces with LLM + return new CreateMessageResult(response); +}; + +// Create client with sampling support +var client = McpClient.sync(transport) + .capabilities(ClientCapabilities.builder() + .sampling() + .build()) + .sampling(samplingHandler) + .build(); +``` + +This capability allows: + +- Servers to leverage AI capabilities without requiring API keys +- Clients to maintain control over model access and permissions +- Support for both text and image-based interactions +- Optional inclusion of MCP server context in prompts + +### Elicitation Support + +Elicitation enables servers to request additional information or user input through the client. This is useful when a server needs clarification or confirmation during an operation: + +```java +// Configure elicitation handler +Function elicitationHandler = request -> { + // Present the request to the user and collect their response + // The request contains a message and a schema describing the expected input + Map userResponse = collectUserInput(request.message(), request.requestedSchema()); + return new ElicitResult(ElicitResult.Action.ACCEPT, userResponse); +}; + +// Create client with elicitation support +var client = McpClient.sync(transport) + .capabilities(ClientCapabilities.builder() + .elicitation() + .build()) + .elicitation(elicitationHandler) + .build(); +``` + +The `ElicitResult` supports three actions: + +- `ACCEPT` - The user accepted and provided the requested information +- `DECLINE` - The user declined to provide the information +- `CANCEL` - The operation was cancelled + +### Logging Support + +The client can register a logging consumer to receive log messages from the server and set the minimum logging level to filter messages: + +```java +var mcpClient = McpClient.sync(transport) + .loggingConsumer(notification -> { + System.out.println("Received log message: " + notification.data()); + }) + .build(); + +mcpClient.initialize(); + +mcpClient.setLoggingLevel(McpSchema.LoggingLevel.INFO); + +// Call the tool that sends logging notifications +CallToolResult result = mcpClient.callTool(new CallToolRequest("logging-test", Map.of())); +``` + +Clients can control the minimum logging level they receive through the `mcpClient.setLoggingLevel(level)` request. Messages below the set level will be filtered out. +Supported logging levels (in order of increasing severity): DEBUG (0), INFO (1), NOTICE (2), WARNING (3), ERROR (4), CRITICAL (5), ALERT (6), EMERGENCY (7) + +### Progress Notifications + +The client can register a progress consumer to track the progress of long-running operations: + +```java +var mcpClient = McpClient.sync(transport) + .progressConsumer(progress -> { + System.out.println("Progress: " + progress.progress() + "/" + progress.total()); + }) + .build(); +``` + +## Using MCP Clients + +### Tool Execution + +Tools are server-side functions that clients can discover and execute. The MCP client provides methods to list available tools and execute them with specific parameters. Each tool has a unique name and accepts a map of parameters. + +=== "Sync API" + + ```java + // List available tools + ListToolsResult tools = client.listTools(); + + // Call a tool with a CallToolRequest + CallToolResult result = client.callTool( + new CallToolRequest("calculator", Map.of( + "operation", "add", + "a", 1, + "b", 2 + )) + ); + ``` + +=== "Async API" + + ```java + // List available tools asynchronously + client.listTools() + .doOnNext(tools -> tools.tools().forEach(tool -> + System.out.println(tool.name()))) + .subscribe(); + + // Call a tool asynchronously + client.callTool(new CallToolRequest("calculator", Map.of( + "operation", "add", + "a", 1, + "b", 2 + ))) + .subscribe(); + ``` + +### Tool Schema Validation and Caching + +The client supports optional JSON schema validation for tool call results and automatic schema caching: + +```java +var client = McpClient.sync(transport) + .jsonSchemaValidator(myValidator) // Enable schema validation + .enableCallToolSchemaCaching(true) // Cache tool schemas + .build(); +``` + +### Resource Access + +Resources represent server-side data sources that clients can access using URI templates. The MCP client provides methods to discover available resources and retrieve their contents through a standardized interface. + +=== "Sync API" + + ```java + // List available resources + ListResourcesResult resources = client.listResources(); + + // Read a resource + ReadResourceResult resource = client.readResource( + new ReadResourceRequest("resource://uri") + ); + ``` + +=== "Async API" + + ```java + // List available resources asynchronously + client.listResources() + .doOnNext(resources -> resources.resources().forEach(resource -> + System.out.println(resource.name()))) + .subscribe(); + + // Read a resource asynchronously + client.readResource(new ReadResourceRequest("resource://uri")) + .subscribe(); + ``` + +### Prompt System + +The prompt system enables interaction with server-side prompt templates. These templates can be discovered and executed with custom parameters, allowing for dynamic text generation based on predefined patterns. + +=== "Sync API" + + ```java + // List available prompt templates + ListPromptsResult prompts = client.listPrompts(); + + // Get a prompt with parameters + GetPromptResult prompt = client.getPrompt( + new GetPromptRequest("greeting", Map.of("name", "World")) + ); + ``` + +=== "Async API" + + ```java + // List available prompt templates asynchronously + client.listPrompts() + .doOnNext(prompts -> prompts.prompts().forEach(prompt -> + System.out.println(prompt.name()))) + .subscribe(); + + // Get a prompt asynchronously + client.getPrompt(new GetPromptRequest("greeting", Map.of("name", "World"))) + .subscribe(); + ``` diff --git a/docs/contribute.md b/docs/contribute.md new file mode 100644 index 000000000..3199dd51f --- /dev/null +++ b/docs/contribute.md @@ -0,0 +1,106 @@ +--- +title: Contributing +description: How to contribute to the MCP Java SDK +--- + +# Contributing + +Thank you for your interest in contributing to the Model Context Protocol Java SDK! +This guide outlines how to contribute to this project. + +## Prerequisites + +!!! info "Required Software" + - **Java 17** or above + - **Docker** + - **npx** + +## Getting Started + +1. Fork the repository +2. Clone your fork: + + ```bash + git clone https://github.com/YOUR-USERNAME/java-sdk.git + cd java-sdk + ``` + +3. Build from source: + + ```bash + ./mvnw clean install -DskipTests # skip the tests + ./mvnw test # run tests + ``` + +## Reporting Issues + +Please create an issue in the repository if you discover a bug or would like to +propose an enhancement. Bug reports should have a reproducer in the form of a code +sample or a repository attached that the maintainers or contributors can work with to +address the problem. + +## Making Changes + +1. Create a new branch: + + ```bash + git checkout -b feature/your-feature-name + ``` + +2. Make your changes. + +3. Validate your changes: + + ```bash + ./mvnw clean test + ``` + +### Change Proposal Guidelines + +#### Principles of MCP + +1. **Simple + Minimal**: It is much easier to add things to the codebase than it is to + remove them. To maintain simplicity, we keep a high bar for adding new concepts and + primitives as each addition requires maintenance and compatibility consideration. +2. **Concrete**: Code changes need to be based on specific usage and implementation + challenges and not on speculative ideas. Most importantly, the SDK is meant to + implement the MCP specification. + +## Submitting Changes + +1. For non-trivial changes, please clarify with the maintainers in an issue whether + you can contribute the change and the desired scope of the change. +2. For trivial changes (for example a couple of lines or documentation changes) there + is no need to open an issue first. +3. Push your changes to your fork. +4. Submit a pull request to the main repository. +5. Follow the pull request template. +6. Wait for review. +7. For any follow-up work, please add new commits instead of force-pushing. This will + allow the reviewer to focus on incremental changes instead of having to restart the + review process. + +## Code of Conduct + +This project follows a Code of Conduct. Please review it in +[CODE_OF_CONDUCT.md](https://github.com/modelcontextprotocol/java-sdk/blob/main/CODE_OF_CONDUCT.md). + +## Questions + +If you have questions, please create a discussion in the repository. + +## License + +By contributing, you agree that your contributions will be licensed under the MIT +License. + +## Security + +This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. + +The security of our systems and user data is Anthropic's top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. + +!!! warning "Reporting Security Vulnerabilities" + Do **not** report security vulnerabilities through public GitHub issues. Instead, report them through our HackerOne [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). + +Our Vulnerability Disclosure Program guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 000000000..e00c7268b --- /dev/null +++ b/docs/development.md @@ -0,0 +1,75 @@ +--- +title: Documentation +description: How to contribute to the MCP Java SDK documentation +--- + +# Documentation Development + +This guide covers how to set up and preview the MCP Java SDK documentation locally. + +!!! info "Prerequisites" + - Python 3.x + - pip (Python package manager) + +## Setup + +Install mkdocs-material: + +```bash +pip install mkdocs-material +``` + +## Preview Locally + +From the project root directory, run: + +```bash +mkdocs serve +``` + +A local preview of the documentation will be available at `http://localhost:8000`. + +### Custom Ports + +By default, mkdocs uses port 8000. You can customize the port with the `-a` flag: + +```bash +mkdocs serve -a localhost:3333 +``` + +## Building + +To build the static site for deployment: + +```bash +mkdocs build +``` + +The built site will be output to the `site/` directory. + +## Project Structure + +``` +docs/ +├── index.md # Overview page +├── quickstart.md # Quickstart guide +├── client.md # MCP Client documentation +├── server.md # MCP Server documentation +├── contributing.md # Contributing guide +├── development.md # This page +├── images/ # Images and diagrams +└── stylesheets/ # Custom CSS +mkdocs.yml # MkDocs configuration +``` + +## Writing Guidelines + +- Documentation pages use standard Markdown with [mkdocs-material extensions](https://squidfunk.github.io/mkdocs-material/reference/) +- Use content tabs (`=== "Tab Label"`) for Maven/Gradle or Sync/Async code examples +- Use admonitions (`!!! tip`, `!!! info`, `!!! warning`) for callouts +- All code blocks should specify a language for syntax highlighting +- Images go in the `docs/images/` directory + +## IDE Support + +We suggest using extensions on your IDE to recognize and format Markdown. If you're a VSCode user, consider the [Markdown All in One](https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one) extension for enhanced Markdown support, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting. diff --git a/docs/images/favicon.svg b/docs/images/favicon.svg new file mode 100644 index 000000000..fe5edb725 --- /dev/null +++ b/docs/images/favicon.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + diff --git a/docs/images/java-mcp-client-architecture.jpg b/docs/images/java-mcp-client-architecture.jpg new file mode 100644 index 000000000..688a2b4ad Binary files /dev/null and b/docs/images/java-mcp-client-architecture.jpg differ diff --git a/docs/images/java-mcp-server-architecture.jpg b/docs/images/java-mcp-server-architecture.jpg new file mode 100644 index 000000000..4b05ca139 Binary files /dev/null and b/docs/images/java-mcp-server-architecture.jpg differ diff --git a/docs/images/java-mcp-uml-classdiagram.svg b/docs/images/java-mcp-uml-classdiagram.svg new file mode 100644 index 000000000..f83a586e7 --- /dev/null +++ b/docs/images/java-mcp-uml-classdiagram.svg @@ -0,0 +1 @@ +McpTransportMono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler)Mono<Void> sendMessage(JSONRPCMessage message)void close()Mono<Void> closeGracefully()<T> T unmarshalFrom(Object data, TypeReference<T> typeRef)McpSession<T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef)Mono<Void> sendNotification(String method, Map<String, Object> params)Mono<Void> closeGracefully()void close()DefaultMcpSessioninterface RequestHandlerinterface NotificationHandlerMcpClientBuilder using(ClientMcpTransport transport)McpAsyncClientMono<InitializeResult> initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully()Mono<Object> ping()Mono<Void> addRoot(Root root)Mono<Void> removeRoot(String rootUri)Mono<Void> rootsListChangedNotification()Mono<CallToolResult> callTool(CallToolRequest request)Mono<ListToolsResult> listTools()Mono<ListResourcesResult> listResources()Mono<ReadResourceResult> readResource(ReadResourceRequest request)Mono<ListResourceTemplatesResult> listResourceTemplates()Mono<Void> subscribeResource(SubscribeRequest request)Mono<Void> unsubscribeResource(UnsubscribeRequest request)Mono<ListPromptsResult> listPrompts()Mono<GetPromptResult> getPrompt(GetPromptRequest request)Mono<Void> setLoggingLevel(LoggingLevel level)McpSyncClientInitializeResult initialize()ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()boolean closeGracefully()Object ping()void addRoot(Root root)void removeRoot(String rootUri)void rootsListChangedNotification()CallToolResult callTool(CallToolRequest request)ListToolsResult listTools()ListResourcesResult listResources()ReadResourceResult readResource(ReadResourceRequest request)ListResourceTemplatesResult listResourceTemplates()void subscribeResource(SubscribeRequest request)void unsubscribeResource(UnsubscribeRequest request)ListPromptsResult listPrompts()GetPromptResult getPrompt(GetPromptRequest request)void setLoggingLevel(LoggingLevel level)McpServerBuilder using(ServerMcpTransport transport)McpAsyncServerServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()Mono<Void> closeGracefully() Mono<Void> addTool(ToolRegistration toolRegistration)Mono<Void> removeTool(String toolName)Mono<Void> notifyToolsListChanged() Mono<Void> addResource(ResourceRegistration resourceHandler)Mono<Void> removeResource(String resourceUri)Mono<Void> notifyResourcesListChanged() Mono<Void> addPrompt(PromptRegistration promptRegistration)Mono<Void> removePrompt(String promptName)Mono<Void> notifyPromptsListChanged() Mono<Void> loggingNotification(LoggingMessageNotification notification) Mono<CreateMessageResult> createMessage(CreateMessageRequest request)McpSyncServerMcpAsyncServer getAsyncServer() ServerCapabilities getServerCapabilities()Implementation getServerInfo()ClientCapabilities getClientCapabilities()Implementation getClientInfo()void close()void closeGracefully() void addTool(ToolRegistration toolHandler)void removeTool(String toolName)void notifyToolsListChanged() void addResource(ResourceRegistration resourceHandler)void removeResource(String resourceUri)void notifyResourcesListChanged() void addPrompt(PromptRegistration promptRegistration)void removePrompt(String promptName)void notifyPromptsListChanged() void loggingNotification(LoggingMessageNotification notification) CreateMessageResult createMessage(CreateMessageRequest request)StdioClientTransportvoid setErrorHandler(Consumer<String> errorHandler)Sinks.Many<String> getErrorSink()ClientMcpTransportStdioServerTransportServerMcpTransportHttpServletSseServerTransportHttpClientSseClientTransportWebFluxSseClientTransportWebFluxSseServerTransportRouterFunction<?> getRouterFunction()WebMvcSseServerTransportRouterFunction<?> getRouterFunction()McpSchemaclass ErrorCodesinterface Requestinterface JSONRPCMessageinterface ResourceContentsinterface Contentinterface ServerCapabilitiesJSONRPCMessage deserializeJsonRpcMessage()McpErrorcreatescreatesdelegates tocreatescreatesusesthrows \ No newline at end of file diff --git a/docs/images/logo-dark.svg b/docs/images/logo-dark.svg new file mode 100644 index 000000000..03d9f85d3 --- /dev/null +++ b/docs/images/logo-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/docs/images/logo-light.svg b/docs/images/logo-light.svg new file mode 100644 index 000000000..fe5edb725 --- /dev/null +++ b/docs/images/logo-light.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + diff --git a/docs/images/mcp-stack.svg b/docs/images/mcp-stack.svg new file mode 100644 index 000000000..3847eaa8d --- /dev/null +++ b/docs/images/mcp-stack.svg @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..e6062b5ff --- /dev/null +++ b/docs/index.md @@ -0,0 +1,84 @@ +--- +title: Index +description: Introduction to the Model Context Protocol (MCP) Java SDK +--- + +# MCP Java SDK + +Java SDK for the [Model Context Protocol](https://modelcontextprotocol.io/docs/concepts/architecture) +enables standardized integration between AI models and tools. + +## Features + +- MCP Client and MCP Server implementations supporting: + - Protocol [version compatibility negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization) with multiple protocol versions + - [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) discovery, execution, list change notifications, and structured output with schema validation + - [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) management with URI templates + - [Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) list management and notifications + - [Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) handling and management + - [Sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) support for AI model interactions + - [Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) support for requesting user input from servers + - [Completions](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) for argument autocompletion suggestions + - [Progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) - progress notifications for tracking long-running operations + - [Logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) - structured logging with configurable severity levels +- Multiple transport implementations: + - Default transports (included in core `mcp` module, no external web frameworks required): + - [STDIO](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#stdio)-based transport for process-based communication + - 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 (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 + - WebMVC Streamable HTTP server transport + - WebMVC Stateless server transport +- Supports Synchronous and Asynchronous programming paradigms +- Pluggable JSON serialization (Jackson 2.x and Jackson 3.x) +- Pluggable authorization hooks for server security +- DNS rebinding protection with Host/Origin header validation + +!!! 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 (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 + +

+ +- :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/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 new file mode 100644 index 000000000..e7e76bc88 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,163 @@ +--- +title: Quickstart +description: Get started with the MCP Java SDK dependencies and configuration +--- + +# Quickstart + +## Dependencies + +Add the following dependency to your project: + +=== "Maven" + + The convenience `mcp` module bundles `mcp-core` with Jackson 3.x JSON serialization: + + ```xml + + io.modelcontextprotocol.sdk + mcp + + ``` + + This includes default STDIO, SSE, and Streamable HTTP transport implementations without requiring external web frameworks. + + If you need only the core module without a JSON implementation (e.g., to bring your own): + + ```xml + + io.modelcontextprotocol.sdk + mcp-core + + ``` + + For Jackson 2.x instead of Jackson 3.x: + + ```xml + + io.modelcontextprotocol.sdk + mcp-core + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + + ``` + + 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 + + + org.springframework.ai + mcp-spring-webflux + + + + + 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: + + ```groovy + dependencies { + implementation "io.modelcontextprotocol.sdk:mcp" + } + ``` + + This includes default STDIO, SSE, and Streamable HTTP transport implementations without requiring external web frameworks. + + If you need only the core module without a JSON implementation (e.g., to bring your own): + + ```groovy + dependencies { + implementation "io.modelcontextprotocol.sdk:mcp-core" + } + ``` + + For Jackson 2.x instead of Jackson 3.x: + + ```groovy + dependencies { + implementation "io.modelcontextprotocol.sdk:mcp-core" + implementation "io.modelcontextprotocol.sdk:mcp-json-jackson2" + } + ``` + + 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 (Spring AI 2.0+) + dependencies { + implementation "org.springframework.ai:mcp-spring-webflux" + } + + // Optional: Spring WebMVC-based SSE and Streamable HTTP server transport (Spring AI 2.0+) + dependencies { + implementation "org.springframework.ai:mcp-spring-webmvc" + } + ``` + +## Bill of Materials (BOM) + +The Bill of Materials (BOM) declares the recommended versions of all the dependencies used by a given release. +Using the BOM from your application's build script avoids the need for you to specify and maintain the dependency versions yourself. +Instead, the version of the BOM you're using determines the utilized dependency versions. +It also ensures that you're using supported and tested versions of the dependencies by default, unless you choose to override them. + +Add the BOM to your project: + +=== "Maven" + + ```xml + + + + io.modelcontextprotocol.sdk + mcp-bom + 1.0.0 + pom + import + + + + ``` + +=== "Gradle" + + ```groovy + dependencies { + implementation platform("io.modelcontextprotocol.sdk:mcp-bom:1.0.0") + //... + } + ``` + + Gradle users can also leverage Gradle (5.0+) native support for declaring dependency constraints using a Maven BOM. + This is implemented by adding a 'platform' dependency handler method to the dependencies section of your Gradle build script. + As shown in the snippet above this can then be followed by version-less declarations of the dependencies. + +Replace the version number with the latest version from [Maven Central](https://central.sonatype.com/artifact/io.modelcontextprotocol.sdk/mcp). + +## Available Dependencies + +The following dependencies are available and managed by the BOM: + +- **Core Dependencies** + - `io.modelcontextprotocol.sdk:mcp-core` - Core MCP library providing the base functionality, APIs, and default transport implementations (STDIO, SSE, Streamable HTTP). JSON binding is abstracted for pluggability. + - `io.modelcontextprotocol.sdk:mcp` - Convenience bundle that combines `mcp-core` with `mcp-json-jackson3` for out-of-the-box usage. +- **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 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 new file mode 100644 index 000000000..0753726e2 --- /dev/null +++ b/docs/server.md @@ -0,0 +1,761 @@ +--- +title: MCP Server +description: Learn how to implement and configure a Model Context Protocol (MCP) server +--- + +# MCP Server + +## Overview + +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, responsible for: + +- Exposing tools that clients can discover and execute +- Managing resources with URI-based access patterns and resource templates +- Providing prompt templates and handling prompt requests +- Supporting capability negotiation with clients +- Providing argument autocompletion suggestions (completions) +- Implementing server-side protocol operations +- Managing concurrent client connections +- Providing structured logging and notifications + +!!! 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 (`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. + +=== "Sync API" + + ```java + // Create a server with custom configuration + McpSyncServer syncServer = McpServer.sync(transportProvider) + .serverInfo("my-server", "1.0.0") + .capabilities(ServerCapabilities.builder() + .resources(false, true) // Enable resource support with list changes + .tools(true) // Enable tool support with list changes + .prompts(true) // Enable prompt support with list changes + .completions() // Enable completions support + .logging() // Enable logging support + .build()) + .build(); + + // Register tools, resources, and prompts + syncServer.addTool(syncToolSpecification); + syncServer.addResource(syncResourceSpecification); + syncServer.addPrompt(syncPromptSpecification); + + // Close the server when done + syncServer.close(); + ``` + +=== "Async API" + + ```java + // Create an async server with custom configuration + McpAsyncServer asyncServer = McpServer.async(transportProvider) + .serverInfo("my-server", "1.0.0") + .capabilities(ServerCapabilities.builder() + .resources(false, true) // Enable resource support with list changes + .tools(true) // Enable tool support with list changes + .prompts(true) // Enable prompt support with list changes + .completions() // Enable completions support + .logging() // Enable logging support + .build()) + .build(); + + // Register tools, resources, and prompts + asyncServer.addTool(asyncToolSpecification) + .doOnSuccess(v -> logger.info("Tool registered")) + .subscribe(); + + asyncServer.addResource(asyncResourceSpecification) + .doOnSuccess(v -> logger.info("Resource registered")) + .subscribe(); + + asyncServer.addPrompt(asyncPromptSpecification) + .doOnSuccess(v -> logger.info("Prompt registered")) + .subscribe(); + + // Close the server when done + asyncServer.close() + .doOnSuccess(v -> logger.info("Server closed")) + .subscribe(); + ``` + +### Server Types + +The SDK supports multiple server creation patterns depending on your transport requirements: + +```java +// Single-session server with SSE transport provider +McpSyncServer server = McpServer.sync(sseTransportProvider).build(); + +// Streamable HTTP server +McpSyncServer server = McpServer.sync(streamableTransportProvider).build(); + +// Stateless server (no session management) +McpSyncServer server = McpServer.sync(statelessTransport).build(); +``` + +## Server Transport Providers + +The transport layer in the MCP SDK is responsible for handling the communication between clients and servers. +It provides different implementations to support various communication protocols and patterns. +The SDK includes several built-in transport provider implementations: + +### STDIO + +Create process-based transport using stdin/stdout: + +```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. + +Key features: + +- Bidirectional communication through stdin/stdout +- Process-based integration support +- Simple setup and configuration +- Lightweight implementation + +### Streamable HTTP + +=== "Streamable HTTP Servlet" + + Creates a Servlet-based Streamable HTTP server transport. Included in the core `mcp` module: + + ```java + HttpServletStreamableServerTransportProvider transportProvider = + HttpServletStreamableServerTransportProvider.builder() + .jsonMapper(jsonMapper) + .mcpEndpoint("/mcp") + .build(); + ``` + + To use with a Spring Web application, register it as a Servlet bean: + + ```java + @Configuration + @EnableWebMvc + public class McpServerConfig implements WebMvcConfigurer { + + @Bean + public HttpServletStreamableServerTransportProvider transportProvider(McpJsonMapper jsonMapper) { + return HttpServletStreamableServerTransportProvider.builder() + .jsonMapper(jsonMapper) + .mcpEndpoint("/mcp") + .build(); + } + + @Bean + public ServletRegistrationBean mcpServlet( + HttpServletStreamableServerTransportProvider transportProvider) { + return new ServletRegistrationBean<>(transportProvider); + } + } + ``` + + Key features: + + - Efficient bidirectional HTTP communication + - Session management for multiple client connections + - Configurable keep-alive intervals + - Security validation support + - Graceful shutdown support + +=== "Streamable HTTP WebFlux (external)" + + 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 + class McpConfig { + @Bean + WebFluxStreamableServerTransportProvider transportProvider(McpJsonMapper jsonMapper) { + return WebFluxStreamableServerTransportProvider.builder() + .jsonMapper(jsonMapper) + .messageEndpoint("/mcp") + .build(); + } + + @Bean + RouterFunction mcpRouterFunction( + WebFluxStreamableServerTransportProvider transportProvider) { + return transportProvider.getRouterFunction(); + } + } + ``` + + Key features: + + - Reactive HTTP streaming with WebFlux + - Concurrent client connections + - Configurable keep-alive intervals + - Security validation support + +=== "Streamable HTTP WebMvc (external)" + + 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 + @EnableWebMvc + class McpConfig { + @Bean + WebMvcStreamableServerTransportProvider transportProvider(McpJsonMapper jsonMapper) { + return WebMvcStreamableServerTransportProvider.builder() + .jsonMapper(jsonMapper) + .mcpEndpoint("/mcp") + .build(); + } + + @Bean + RouterFunction mcpRouterFunction( + WebMvcStreamableServerTransportProvider transportProvider) { + return transportProvider.getRouterFunction(); + } + } + ``` + +### 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 from [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`): + + ```java + @Configuration + class McpConfig { + @Bean + WebFluxSseServerTransportProvider webFluxSseServerTransportProvider(ObjectMapper mapper) { + return new WebFluxSseServerTransportProvider(mapper, "/mcp/message"); + } + + @Bean + RouterFunction mcpRouterFunction(WebFluxSseServerTransportProvider transportProvider) { + return transportProvider.getRouterFunction(); + } + } + ``` + + Implements the MCP HTTP with SSE transport specification, providing: + + - Reactive HTTP streaming with WebFlux + - Concurrent client connections through SSE endpoints + - Message routing and session management + - Graceful shutdown capabilities + +=== "SSE WebMvc (external)" + + Creates WebMvc-based SSE server transport. Requires the `mcp-spring-webmvc` dependency from [Spring AI](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) 2.0+ (group `org.springframework.ai`): + + ```java + @Configuration + @EnableWebMvc + class McpConfig { + @Bean + WebMvcSseServerTransportProvider webMvcSseServerTransportProvider(ObjectMapper mapper) { + return new WebMvcSseServerTransportProvider(mapper, "/mcp/message"); + } + + @Bean + RouterFunction mcpRouterFunction( + WebMvcSseServerTransportProvider transportProvider) { + return transportProvider.getRouterFunction(); + } + } + ``` + + Implements the MCP HTTP with SSE transport specification, providing: + + - Server-side event streaming + - Integration with Spring WebMVC + - Support for traditional web applications + - Synchronous operation handling + + +## Server Capabilities + +The server can be configured with various capabilities: + +```java +var capabilities = ServerCapabilities.builder() + .resources(false, true) // Resource support (subscribe, listChanged) + .tools(true) // Tool support with list changes notifications + .prompts(true) // Prompt support with list changes notifications + .completions() // Enable completions support + .logging() // Enable logging support + .build(); +``` + +### Tool Specification + +The Model Context Protocol allows servers to [expose tools](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/) that can be invoked by language models. +The Java SDK allows implementing Tool Specifications with their handler functions. +Tools enable AI models to perform calculations, access external APIs, query databases, and manipulate files. + +The recommended approach is to use the builder pattern and `CallToolRequest` as the handler parameter: + +=== "Sync" + + ```java + // Sync tool specification using builder + var syncToolSpecification = SyncToolSpecification.builder() + .tool(Tool.builder() + .name("calculator") + .description("Basic calculator") + .inputSchema(schema) + .build()) + .callHandler((exchange, request) -> { + // Access arguments via request.arguments() + String operation = (String) request.arguments().get("operation"); + int a = (int) request.arguments().get("a"); + int b = (int) request.arguments().get("b"); + // Tool implementation + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Result: " + result))) + .build(); + }) + .build(); + ``` + +=== "Async" + + ```java + // Async tool specification using builder + var asyncToolSpecification = AsyncToolSpecification.builder() + .tool(Tool.builder() + .name("calculator") + .description("Basic calculator") + .inputSchema(schema) + .build()) + .callHandler((exchange, request) -> { + // Access arguments via request.arguments() + String operation = (String) request.arguments().get("operation"); + int a = (int) request.arguments().get("a"); + int b = (int) request.arguments().get("b"); + // Tool implementation + return Mono.just(CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Result: " + result))) + .build()); + }) + .build(); + ``` + +The Tool specification includes a Tool definition with `name`, `description`, and `inputSchema` followed by a call handler that implements the tool's logic. +The handler receives `McpSyncServerExchange`/`McpAsyncServerExchange` for client interaction and a `CallToolRequest` containing the tool arguments. + +You can also register tools directly on the server builder using the `toolCall` convenience method: + +```java +var server = McpServer.sync(transportProvider) + .toolCall( + Tool.builder().name("echo").description("Echoes input").inputSchema(schema).build(), + (exchange, request) -> CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(request.arguments().get("text").toString()))) + .build() + ) + .build(); +``` + +### Resource Specification + +Specification of a resource with its handler function. +Resources provide context to AI models by exposing data such as: File contents, Database records, API responses, System information, Application state. + +=== "Sync" + + ```java + // Sync resource specification + var syncResourceSpecification = new McpServerFeatures.SyncResourceSpecification( + Resource.builder() + .uri("custom://resource") + .name("name") + .description("description") + .mimeType("text/plain") + .build(), + (exchange, request) -> { + // Resource read implementation + return new ReadResourceResult(contents); + } + ); + ``` + +=== "Async" + + ```java + // Async resource specification + var asyncResourceSpecification = new McpServerFeatures.AsyncResourceSpecification( + Resource.builder() + .uri("custom://resource") + .name("name") + .description("description") + .mimeType("text/plain") + .build(), + (exchange, request) -> { + // Resource read implementation + return Mono.just(new ReadResourceResult(contents)); + } + ); + ``` + +### Resource Template Specification + +Resource templates allow servers to expose parameterized resources using URI templates: + +```java +// Resource template specification +var resourceTemplateSpec = new McpServerFeatures.SyncResourceTemplateSpecification( + ResourceTemplate.builder() + .uriTemplate("file://{path}") + .name("File Resource") + .description("Access files by path") + .mimeType("application/octet-stream") + .build(), + (exchange, request) -> { + // Read the file at the requested URI + return new ReadResourceResult(contents); + } +); +``` + +### Prompt Specification + +As part of the [Prompting capabilities](https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/), MCP provides a standardized way for servers to expose prompt templates to clients. +The Prompt Specification is a structured template for AI model interactions that enables consistent message formatting, parameter substitution, context injection, response formatting, and instruction templating. + +=== "Sync" + + ```java + // Sync prompt specification + var syncPromptSpecification = new McpServerFeatures.SyncPromptSpecification( + new Prompt("greeting", "description", List.of( + new PromptArgument("name", "description", true) + )), + (exchange, request) -> { + // Prompt implementation + return new GetPromptResult(description, messages); + } + ); + ``` + +=== "Async" + + ```java + // Async prompt specification + var asyncPromptSpecification = new McpServerFeatures.AsyncPromptSpecification( + new Prompt("greeting", "description", List.of( + new PromptArgument("name", "description", true) + )), + (exchange, request) -> { + // Prompt implementation + return Mono.just(new GetPromptResult(description, messages)); + } + ); + ``` + +The prompt definition includes name (identifier for the prompt), description (purpose of the prompt), and list of arguments (parameters for templating). +The handler function processes requests and returns formatted templates. +The first argument is `McpSyncServerExchange`/`McpAsyncServerExchange` for client interaction, and the second argument is a `GetPromptRequest` instance. + +### Completion Specification + +Completions allow servers to provide argument autocompletion suggestions for prompts and resources: + +=== "Sync" + + ```java + // Sync completion specification + var syncCompletionSpec = new McpServerFeatures.SyncCompletionSpecification( + new McpSchema.PromptReference("greeting"), // Reference to a prompt + (exchange, request) -> { + String argName = request.argument().name(); + String partial = request.argument().value(); + // Return matching suggestions + List suggestions = findMatches(partial); + return new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(suggestions, suggestions.size(), false) + ); + } + ); + ``` + +=== "Async" + + ```java + // Async completion specification + var asyncCompletionSpec = new McpServerFeatures.AsyncCompletionSpecification( + new McpSchema.PromptReference("greeting"), + (exchange, request) -> { + String argName = request.argument().name(); + String partial = request.argument().value(); + List suggestions = findMatches(partial); + return Mono.just(new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(suggestions, suggestions.size(), false) + )); + } + ); + ``` + +Completions can be registered for both `PromptReference` and `ResourceReference` types. + +### Using Sampling from a Server + +To use [Sampling capabilities](https://spec.modelcontextprotocol.io/specification/2024-11-05/client/sampling/), connect to a client that supports sampling. +No special server configuration is needed, but verify client sampling support before making requests. +Learn about [client sampling support](client.md#sampling-support). + +Once connected to a compatible client, the server can request language model generations: + +=== "Sync API" + + ```java + // Create a server + McpSyncServer server = McpServer.sync(transportProvider) + .serverInfo("my-server", "1.0.0") + .build(); + + // Define a tool that uses sampling + var calculatorTool = SyncToolSpecification.builder() + .tool(Tool.builder() + .name("ai-calculator") + .description("Performs calculations using AI") + .inputSchema(schema) + .build()) + .callHandler((exchange, request) -> { + // Check if client supports sampling + if (exchange.getClientCapabilities().sampling() == null) { + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Client does not support AI capabilities"))) + .build(); + } + + // Create a sampling request + CreateMessageRequest samplingRequest = CreateMessageRequest.builder() + .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Calculate: " + request.arguments().get("expression"))))) + .modelPreferences(McpSchema.ModelPreferences.builder() + .hints(List.of( + McpSchema.ModelHint.of("claude-3-sonnet"), + McpSchema.ModelHint.of("claude") + )) + .intelligencePriority(0.8) + .speedPriority(0.5) + .build()) + .systemPrompt("You are a helpful calculator assistant. Provide only the numerical answer.") + .maxTokens(100) + .build(); + + // Request sampling from the client + CreateMessageResult result = exchange.createMessage(samplingRequest); + + // Process the result + String answer = ((McpSchema.TextContent) result.content()).text(); + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(answer))) + .build(); + }) + .build(); + + // Add the tool to the server + server.addTool(calculatorTool); + ``` + +=== "Async API" + + ```java + // Create a server + McpAsyncServer server = McpServer.async(transportProvider) + .serverInfo("my-server", "1.0.0") + .build(); + + // Define a tool that uses sampling + var calculatorTool = AsyncToolSpecification.builder() + .tool(Tool.builder() + .name("ai-calculator") + .description("Performs calculations using AI") + .inputSchema(schema) + .build()) + .callHandler((exchange, request) -> { + // Check if client supports sampling + if (exchange.getClientCapabilities().sampling() == null) { + return Mono.just(CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Client does not support AI capabilities"))) + .build()); + } + + // Create a sampling request + CreateMessageRequest samplingRequest = CreateMessageRequest.builder() + .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Calculate: " + request.arguments().get("expression"))))) + .modelPreferences(McpSchema.ModelPreferences.builder() + .hints(List.of( + McpSchema.ModelHint.of("claude-3-sonnet"), + McpSchema.ModelHint.of("claude") + )) + .intelligencePriority(0.8) + .speedPriority(0.5) + .build()) + .systemPrompt("You are a helpful calculator assistant. Provide only the numerical answer.") + .maxTokens(100) + .build(); + + // Request sampling from the client + return exchange.createMessage(samplingRequest) + .map(result -> { + String answer = ((McpSchema.TextContent) result.content()).text(); + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(answer))) + .build(); + }); + }) + .build(); + + // Add the tool to the server + server.addTool(calculatorTool) + .subscribe(); + ``` + +The `CreateMessageRequest` object allows you to specify: `Content` - the input text or image for the model, +`Model Preferences` - hints and priorities for model selection, `System Prompt` - instructions for the model's behavior and +`Max Tokens` - maximum length of the generated response. + +### Using Elicitation from a Server + +Servers can request user input from connected clients that support elicitation: + +```java +var tool = SyncToolSpecification.builder() + .tool(Tool.builder() + .name("confirm-action") + .description("Confirms an action with the user") + .inputSchema(schema) + .build()) + .callHandler((exchange, request) -> { + // Check if client supports elicitation + if (exchange.getClientCapabilities().elicitation() == null) { + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Client does not support elicitation"))) + .build(); + } + + // Request user confirmation + ElicitRequest elicitRequest = ElicitRequest.builder() + .message("Do you want to proceed with this action?") + .requestedSchema(Map.of( + "type", "object", + "properties", Map.of("confirmed", Map.of("type", "boolean")) + )) + .build(); + + ElicitResult result = exchange.elicit(elicitRequest); + + if (result.action() == ElicitResult.Action.ACCEPT) { + // User accepted + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Action confirmed"))) + .build(); + } else { + return CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Action declined"))) + .build(); + } + }) + .build(); +``` + +### Logging Support + +The server provides structured logging capabilities that allow sending log messages to clients with different severity levels. +Log notifications can only be sent from within an existing client session, such as tools, resources, and prompts calls. + +The server can send log messages using the `McpAsyncServerExchange`/`McpSyncServerExchange` object in the tool/resource/prompt handler function: + +```java +var tool = new McpServerFeatures.AsyncToolSpecification( + Tool.builder().name("logging-test").description("Test logging notifications").inputSchema(emptyJsonSchema).build(), + null, + (exchange, request) -> { + + exchange.loggingNotification( // Use the exchange to send log messages + McpSchema.LoggingMessageNotification.builder() + .level(McpSchema.LoggingLevel.DEBUG) + .logger("test-logger") + .data("Debug message") + .build()) + .block(); + + return Mono.just(CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("Logging test completed"))) + .build()); + }); + +var mcpServer = McpServer.async(mcpServerTransportProvider) + .serverInfo("test-server", "1.0.0") + .capabilities( + ServerCapabilities.builder() + .logging() // Enable logging support + .tools(true) + .build()) + .tools(tool) + .build(); +``` + +On the client side, you can register a logging consumer to receive log messages from the server: + +```java +var mcpClient = McpClient.sync(transport) + .loggingConsumer(notification -> { + System.out.println("Received log message: " + notification.data()); + }) + .build(); + +mcpClient.initialize(); +mcpClient.setLoggingLevel(McpSchema.LoggingLevel.INFO); +``` + +Clients can control the minimum logging level they receive through the `mcpClient.setLoggingLevel(level)` request. Messages below the set level will be filtered out. +Supported logging levels (in order of increasing severity): DEBUG (0), INFO (1), NOTICE (2), WARNING (3), ERROR (4), CRITICAL (5), ALERT (6), EMERGENCY (7) + +## Error Handling + +The SDK provides comprehensive error handling through the McpError class, covering protocol compatibility, transport communication, JSON-RPC messaging, tool execution, resource management, prompt handling, timeouts, and connection issues. This unified error handling approach ensures consistent and reliable error management across both synchronous and asynchronous operations. diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index 447c9e0bd..fb6f3a32a 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.18.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp-bom @@ -40,13 +40,6 @@ ${project.version} - - - io.modelcontextprotocol.sdk - mcp-json - ${project.version} - - io.modelcontextprotocol.sdk @@ -61,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 9e23ffd79..4de0fba2b 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.18.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp-core jar @@ -35,13 +35,13 @@ - - io.modelcontextprotocol.sdk - mcp-json - 0.18.0-SNAPSHOT - org.slf4j @@ -80,7 +75,7 @@ com.fasterxml.jackson.core jackson-annotations - ${jackson.version} + ${jackson-annotations.version} @@ -97,45 +92,6 @@ provided - - - io.modelcontextprotocol.sdk - mcp-json-jackson2 - 0.18.0-SNAPSHOT - test - - - - org.springframework - spring-webmvc - ${springframework.version} - 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 @@ -201,20 +157,6 @@ test - - - org.apache.tomcat.embed - tomcat-embed-core - ${tomcat.version} - test - - - org.apache.tomcat.embed - tomcat-embed-websocket - ${tomcat.version} - test - - org.testcontainers toxiproxy diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index e6a09cd08..93fcc332a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -30,6 +30,7 @@ import io.modelcontextprotocol.spec.McpSchema.ElicitResult; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.util.ToolNameValidator; import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; @@ -656,6 +657,10 @@ private Mono listToolsInternal(Initialization init, S .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), LIST_TOOLS_RESULT_TYPE_REF) .doOnNext(result -> { + // Validate tool names (warn only) + if (result.tools() != null) { + result.tools().forEach(tool -> ToolNameValidator.validate(tool.name(), false)); + } if (this.enableCallToolSchemaCaching && result.tools() != null) { // Cache tools output schema result.tools() diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index c9989f832..12f34e60a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -5,6 +5,7 @@ package io.modelcontextprotocol.client; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.spec.McpClientTransport; import io.modelcontextprotocol.spec.McpSchema; @@ -492,7 +493,7 @@ public McpSyncClient build() { McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, - jsonSchemaValidator != null ? jsonSchemaValidator : JsonSchemaValidator.getDefault(), + jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(), asyncFeatures), this.contextProvider); } @@ -826,7 +827,7 @@ public AsyncSpec enableCallToolSchemaCaching(boolean enableCallToolSchemaCaching */ public McpAsyncClient build() { var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator - : JsonSchemaValidator.getDefault(); + : McpJsonDefaults.getSchemaValidator(); return new McpAsyncClient(this.transport, this.requestTimeout, this.initializationTimeout, jsonSchemaValidator, new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots, 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 ae093316f..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 @@ -22,6 +22,7 @@ import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; 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.json.TypeRef; import io.modelcontextprotocol.spec.HttpHeaders; @@ -184,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 @@ -327,7 +315,7 @@ public Builder connectTimeout(Duration connectTimeout) { public HttpClientSseClientTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint, - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, httpRequestCustomizer); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index e41f45ebb..d6b01e17f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -25,6 +25,7 @@ import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; 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.json.TypeRef; import io.modelcontextprotocol.spec.ClosedMcpTransportSession; @@ -295,12 +296,23 @@ private Mono reconnect(McpTransportStream stream) { if (statusCode >= 200 && statusCode < 300) { if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) { + String data = responseEvent.sseEvent().data(); + // Per 2025-11-25 spec (SEP-1699), servers may + // send SSE events + // with empty data to prime the client for + // reconnection. + // Skip these events as they contain no JSON-RPC + // message. + if (data == null || data.isBlank()) { + logger.debug("Skipping SSE event with empty data (stream primer)"); + return Flux.empty(); + } 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, responseEvent.sseEvent().data()); + McpSchema.JSONRPCMessage message = McpSchema + .deserializeJsonRpcMessage(this.jsonMapper, data); Tuple2, Iterable> idWithMessages = Tuples .of(Optional.ofNullable(responseEvent.sseEvent().id()), @@ -491,7 +503,9 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { .firstValue(HttpHeaders.CONTENT_LENGTH) .orElse(null); - if (contentType.isBlank() || "0".equals(contentLength)) { + // For empty content or HTTP code 202 (ACCEPTED), assume success + if (contentType.isBlank() || "0".equals(contentLength) || statusCode == 202) { + // if (contentType.isBlank() || "0".equals(contentLength)) { logger.debug("No body returned for POST in session {}", sessionRepresentation); // No content type means no response body, so we can just // return an empty stream @@ -501,13 +515,22 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { else if (contentType.contains(TEXT_EVENT_STREAM)) { return Flux.just(((ResponseSubscribers.SseResponseEvent) responseEvent).sseEvent()) .flatMap(sseEvent -> { + String data = sseEvent.data(); + // Per 2025-11-25 spec (SEP-1699), servers may send SSE + // events + // with empty data to prime the client for reconnection. + // Skip these events as they contain no JSON-RPC message. + if (data == null || data.isBlank()) { + logger.debug("Skipping SSE event with empty data (stream primer)"); + return Flux.empty(); + } 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, sseEvent.data()); + .deserializeJsonRpcMessage(this.jsonMapper, data); Tuple2, Iterable> idWithMessages = Tuples .of(Optional.ofNullable(sseEvent.id()), List.of(message)); @@ -639,7 +662,7 @@ public static class Builder { private Duration connectTimeout = Duration.ofSeconds(10); private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05, - ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18); + ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); /** * Creates a new builder with the specified base URI. @@ -820,7 +843,7 @@ public Builder supportedProtocolVersions(List supportedProtocolVersions) */ public HttpClientStreamableHttpTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); - return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, + return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup, httpRequestCustomizer, supportedProtocolVersions); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java new file mode 100644 index 000000000..11b370ed8 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonDefaults.java @@ -0,0 +1,76 @@ +/** + * Copyright 2026 - 2026 the original author or authors. + */ +package io.modelcontextprotocol.json; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator; +import io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier; +import io.modelcontextprotocol.util.McpServiceLoader; + +/** + * This class is to be used to provide access to the default {@link McpJsonMapper} and to + * the default {@link JsonSchemaValidator} instances via the static methods: + * {@link #getMapper()} and {@link #getSchemaValidator()}. + *

+ * The initialization of (singleton) instances of this class is different in non-OSGi + * environments and OSGi environments. Specifically, in non-OSGi environments the + * {@code McpJsonDefaults} class will be loaded by whatever classloader is used to call + * one of the existing static get methods for the first time. For servers, this will + * usually be in response to the creation of the first {@code McpServer} instance. At that + * first time, the {@code mcpMapperServiceLoader} and {@code mcpValidatorServiceLoader} + * will be null, and the {@code McpJsonDefaults} constructor will be called, + * creating/initializing the {@code mcpMapperServiceLoader} and the + * {@code mcpValidatorServiceLoader}...which will then be used to call the + * {@code ServiceLoader.load} method. + *

+ * In OSGi environments, upon bundle activation SCR will create a new (singleton) instance + * of {@code McpJsonDefaults} (via the constructor), and then inject suppliers via the + * {@code setMcpJsonMapperSupplier} and {@code setJsonSchemaValidatorSupplier} methods + * with the SCR-discovered instances of those services. This does depend upon the + * jars/bundles providing those suppliers to be started/activated. This SCR behavior is + * dictated by xml files in {@code OSGi-INF} directory of {@code mcp-core} (this + * project/jar/bundle), and the jsonmapper and jsonschemavalidator provider jars/bundles + * (e.g. {@code mcp-json-jackson2}, {@code mcp-json-jackson3}, or others). + */ +public class McpJsonDefaults { + + protected static McpServiceLoader mcpMapperServiceLoader; + + protected static McpServiceLoader mcpValidatorServiceLoader; + + public McpJsonDefaults() { + mcpMapperServiceLoader = new McpServiceLoader<>(McpJsonMapperSupplier.class); + mcpValidatorServiceLoader = new McpServiceLoader<>(JsonSchemaValidatorSupplier.class); + } + + void setMcpJsonMapperSupplier(McpJsonMapperSupplier supplier) { + mcpMapperServiceLoader.setSupplier(supplier); + } + + void unsetMcpJsonMapperSupplier(McpJsonMapperSupplier supplier) { + mcpMapperServiceLoader.unsetSupplier(supplier); + } + + public synchronized static McpJsonMapper getMapper() { + if (mcpMapperServiceLoader == null) { + new McpJsonDefaults(); + } + return mcpMapperServiceLoader.getDefault(); + } + + void setJsonSchemaValidatorSupplier(JsonSchemaValidatorSupplier supplier) { + mcpValidatorServiceLoader.setSupplier(supplier); + } + + void unsetJsonSchemaValidatorSupplier(JsonSchemaValidatorSupplier supplier) { + mcpValidatorServiceLoader.unsetSupplier(supplier); + } + + public synchronized static JsonSchemaValidator getSchemaValidator() { + if (mcpValidatorServiceLoader == null) { + new McpJsonDefaults(); + } + return mcpValidatorServiceLoader.getDefault(); + } + +} diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java similarity index 81% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java index 1e30cad16..8481d1703 100644 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapper.java @@ -87,24 +87,4 @@ public interface McpJsonMapper { */ byte[] writeValueAsBytes(Object value) throws IOException; - /** - * Returns the default {@link McpJsonMapper}. - * @return The default {@link McpJsonMapper} - * @throws IllegalStateException If no {@link McpJsonMapper} implementation exists on - * the classpath. - */ - static McpJsonMapper getDefault() { - return McpJsonInternal.getDefaultMapper(); - } - - /** - * Creates a new default {@link McpJsonMapper}. - * @return The default {@link McpJsonMapper} - * @throws IllegalStateException If no {@link McpJsonMapper} implementation exists on - * the classpath. - */ - static McpJsonMapper createDefault() { - return McpJsonInternal.createDefaultMapper(); - } - } diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapperSupplier.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapperSupplier.java similarity index 100% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonMapperSupplier.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/McpJsonMapperSupplier.java diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/TypeRef.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/TypeRef.java similarity index 94% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/TypeRef.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/TypeRef.java index ab37b43f3..725513c66 100644 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/TypeRef.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/TypeRef.java @@ -9,7 +9,7 @@ /** * Captures generic type information at runtime for parameterized JSON (de)serialization. - * Usage: TypeRef<List<Foo>> ref = new TypeRef<>(){}; + * Usage: TypeRef> ref = new TypeRef<>(){}; */ public abstract class TypeRef { diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java similarity index 69% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java index 8e35c0237..09fe604f4 100644 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java @@ -41,24 +41,4 @@ public static ValidationResponse asInvalid(String message) { */ ValidationResponse validate(Map schema, Object structuredContent); - /** - * Creates the default {@link JsonSchemaValidator}. - * @return The default {@link JsonSchemaValidator} - * @throws IllegalStateException If no {@link JsonSchemaValidator} implementation - * exists on the classpath. - */ - static JsonSchemaValidator createDefault() { - return JsonSchemaInternal.createDefaultValidator(); - } - - /** - * Returns the default {@link JsonSchemaValidator}. - * @return The default {@link JsonSchemaValidator} - * @throws IllegalStateException If no {@link JsonSchemaValidator} implementation - * exists on the classpath. - */ - static JsonSchemaValidator getDefault() { - return JsonSchemaInternal.getDefaultValidator(); - } - } diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorSupplier.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorSupplier.java similarity index 100% rename from mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorSupplier.java rename to mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorSupplier.java 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 fe3125271..360eb607d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -4,7 +4,6 @@ package io.modelcontextprotocol.server; -import io.modelcontextprotocol.common.McpTransportContext; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -14,18 +13,19 @@ import java.util.function.BiConsumer; import java.util.function.BiFunction; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; - import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate; import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.McpStatelessServerTransport; import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.DefaultMcpUriTemplateManagerFactory; import io.modelcontextprotocol.util.McpUriTemplateManagerFactory; +import io.modelcontextprotocol.util.ToolNameValidator; import reactor.core.publisher.Mono; /** @@ -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)
@@ -240,9 +240,9 @@ public McpAsyncServer build() {
 					this.instructions);
 
 			var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator
-					: JsonSchemaValidator.getDefault();
+					: McpJsonDefaults.getSchemaValidator();
 
-			return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper,
+			return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
 					features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator);
 		}
 
@@ -267,8 +267,8 @@ public McpAsyncServer build() {
 					this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers,
 					this.instructions);
 			var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
-					: JsonSchemaValidator.getDefault();
-			return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper,
+					: McpJsonDefaults.getSchemaValidator();
+			return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
 					features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator);
 		}
 
@@ -291,6 +291,8 @@ abstract class AsyncSpecification> {
 
 		String instructions;
 
+		boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault();
+
 		/**
 		 * The Model Context Protocol (MCP) allows servers to expose tools that can be
 		 * invoked by language models. Tools enable models to interact with external
@@ -407,6 +409,18 @@ public AsyncSpecification instructions(String instructions) {
 			return this;
 		}
 
+		/**
+		 * Sets whether to use strict tool name validation for this server. When set, this
+		 * takes priority over the system property
+		 * {@code io.modelcontextprotocol.strictToolNameValidation}.
+		 * @param strict true to throw exception on invalid names and false to warn only
+		 * @return This builder instance for method chaining
+		 */
+		public AsyncSpecification strictToolNameValidation(boolean strict) {
+			this.strictToolNameValidation = strict;
+			return this;
+		}
+
 		/**
 		 * Sets the server capabilities that will be advertised to clients during
 		 * connection initialization. Capabilities define what features the server
@@ -427,45 +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"); - 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 @@ -484,6 +459,7 @@ public AsyncSpecification toolCall(McpSchema.Tool tool, Assert.notNull(tool, "Tool must not be null"); Assert.notNull(callHandler, "Handler must not be null"); + validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); this.tools @@ -506,6 +482,7 @@ public AsyncSpecification tools(List tools(McpServerFeatures.AsyncToolSpecification... t Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (McpServerFeatures.AsyncToolSpecification tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } return this; } + private void validateToolName(String toolName) { + ToolNameValidator.validate(toolName, this.strictToolNameValidation); + } + private void assertNoDuplicateTool(String toolName) { if (this.tools.stream().anyMatch(toolSpec -> toolSpec.tool().name().equals(toolName))) { throw new IllegalArgumentException("Tool with name '" + toolName + "' is already registered."); @@ -834,9 +816,9 @@ public McpSyncServer build() { this.immediateExecution); var asyncServer = new McpAsyncServer(transportProvider, - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, asyncFeatures, requestTimeout, + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, uriTemplateManagerFactory, - jsonSchemaValidator != null ? jsonSchemaValidator : JsonSchemaValidator.getDefault()); + jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator()); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -864,9 +846,9 @@ public McpSyncServer build() { McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator - : JsonSchemaValidator.getDefault(); + : McpJsonDefaults.getSchemaValidator(); var asyncServer = new McpAsyncServer(transportProvider, - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, asyncFeatures, this.requestTimeout, + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, this.requestTimeout, this.uriTemplateManagerFactory, jsonSchemaValidator); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -888,6 +870,8 @@ abstract class SyncSpecification> { String instructions; + boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -1008,6 +992,18 @@ public SyncSpecification instructions(String instructions) { return this; } + /** + * Sets whether to use strict tool name validation for this server. When set, this + * takes priority over the system property + * {@code io.modelcontextprotocol.strictToolNameValidation}. + * @param strict true to throw exception on invalid names, false to warn only + * @return This builder instance for method chaining + */ + public SyncSpecification strictToolNameValidation(boolean strict) { + this.strictToolNameValidation = strict; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -1028,44 +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"); - 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 @@ -1083,9 +1041,10 @@ public SyncSpecification toolCall(McpSchema.Tool tool, BiFunction 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, null, handler)); + this.tools.add(new McpServerFeatures.SyncToolSpecification(tool, handler)); return this; } @@ -1105,7 +1064,8 @@ public SyncSpecification tools(List for (var tool : toolSpecifications) { String toolName = tool.tool().name(); - assertNoDuplicateTool(toolName); // Check against existing tools + validateToolName(toolName); + assertNoDuplicateTool(toolName); this.tools.add(tool); } @@ -1133,12 +1093,17 @@ public SyncSpecification tools(McpServerFeatures.SyncToolSpecification... too Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (McpServerFeatures.SyncToolSpecification tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } return this; } + private void validateToolName(String toolName) { + ToolNameValidator.validate(toolName, this.strictToolNameValidation); + } + private void assertNoDuplicateTool(String toolName) { if (this.tools.stream().anyMatch(toolSpec -> toolSpec.tool().name().equals(toolName))) { throw new IllegalArgumentException("Tool with name '" + toolName + "' is already registered."); @@ -1434,6 +1399,8 @@ class StatelessAsyncSpecification { String instructions; + boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -1551,6 +1518,18 @@ public StatelessAsyncSpecification instructions(String instructions) { return this; } + /** + * Sets whether to use strict tool name validation for this server. When set, this + * takes priority over the system property + * {@code io.modelcontextprotocol.strictToolNameValidation}. + * @param strict true to throw exception on invalid names, false to warn only + * @return This builder instance for method chaining + */ + public StatelessAsyncSpecification strictToolNameValidation(boolean strict) { + this.strictToolNameValidation = strict; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -1589,6 +1568,7 @@ public StatelessAsyncSpecification toolCall(McpSchema.Tool tool, Assert.notNull(tool, "Tool must not be null"); Assert.notNull(callHandler, "Handler must not be null"); + validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); this.tools.add(new McpStatelessServerFeatures.AsyncToolSpecification(tool, callHandler)); @@ -1611,6 +1591,7 @@ public StatelessAsyncSpecification tools( Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (var tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } @@ -1639,12 +1620,17 @@ public StatelessAsyncSpecification tools( Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (var tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } return this; } + private void validateToolName(String toolName) { + ToolNameValidator.validate(toolName, this.strictToolNameValidation); + } + private void assertNoDuplicateTool(String toolName) { if (this.tools.stream().anyMatch(toolSpec -> toolSpec.tool().name().equals(toolName))) { throw new IllegalArgumentException("Tool with name '" + toolName + "' is already registered."); @@ -1871,9 +1857,9 @@ public StatelessAsyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonS public McpStatelessAsyncServer build() { var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); - return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, + return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, features, requestTimeout, uriTemplateManagerFactory, - jsonSchemaValidator != null ? jsonSchemaValidator : JsonSchemaValidator.getDefault()); + jsonSchemaValidator != null ? jsonSchemaValidator : McpJsonDefaults.getSchemaValidator()); } } @@ -1896,6 +1882,8 @@ class StatelessSyncSpecification { String instructions; + boolean strictToolNameValidation = ToolNameValidator.isStrictByDefault(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -2013,6 +2001,18 @@ public StatelessSyncSpecification instructions(String instructions) { return this; } + /** + * Sets whether to use strict tool name validation for this server. When set, this + * takes priority over the system property + * {@code io.modelcontextprotocol.strictToolNameValidation}. + * @param strict true to throw exception on invalid names, false to warn only + * @return This builder instance for method chaining + */ + public StatelessSyncSpecification strictToolNameValidation(boolean strict) { + this.strictToolNameValidation = strict; + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -2051,6 +2051,7 @@ public StatelessSyncSpecification toolCall(McpSchema.Tool tool, Assert.notNull(tool, "Tool must not be null"); Assert.notNull(callHandler, "Handler must not be null"); + validateToolName(tool.name()); assertNoDuplicateTool(tool.name()); this.tools.add(new McpStatelessServerFeatures.SyncToolSpecification(tool, callHandler)); @@ -2073,6 +2074,7 @@ public StatelessSyncSpecification tools( Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (var tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } @@ -2101,12 +2103,17 @@ public StatelessSyncSpecification tools( Assert.notNull(toolSpecifications, "Tool handlers list must not be null"); for (var tool : toolSpecifications) { + validateToolName(tool.tool().name()); assertNoDuplicateTool(tool.tool().name()); this.tools.add(tool); } return this; } + private void validateToolName(String toolName) { + ToolNameValidator.validate(toolName, this.strictToolNameValidation); + } + private void assertNoDuplicateTool(String toolName) { if (this.tools.stream().anyMatch(toolSpec -> toolSpec.tool().name().equals(toolName))) { throw new IllegalArgumentException("Tool with name '" + toolName + "' is already registered."); @@ -2351,9 +2358,9 @@ public McpStatelessSyncServer build() { this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var asyncServer = new McpStatelessAsyncServer(transport, - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, asyncFeatures, requestTimeout, + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, uriTemplateManagerFactory, - this.jsonSchemaValidator != null ? this.jsonSchemaValidator : JsonSchemaValidator.getDefault()); + this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator()); return new McpStatelessSyncServer(asyncServer, this.immediateExecution); } 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/DefaultServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java new file mode 100644 index 000000000..e96403e48 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java @@ -0,0 +1,204 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.util.Assert; + +/** + * Default implementation of {@link ServerTransportSecurityValidator} that validates the + * Origin and Host headers against lists of allowed values. + * + *

+ * Supports exact matches and wildcard port patterns (e.g., "http://example.com:*" for + * origins, "example.com:*" for hosts). + * + * @author Daniel Garnier-Moiroux + * @see ServerTransportSecurityValidator + * @see ServerTransportSecurityException + */ +public final class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator { + + private static final String ORIGIN_HEADER = "Origin"; + + private static final String HOST_HEADER = "Host"; + + private final List allowedOrigins; + + private final List allowedHosts; + + /** + * Creates a new validator with the specified allowed origins and hosts. + * @param allowedOrigins List of allowed origin patterns. Supports exact matches + * (e.g., "http://example.com:8080") and wildcard ports (e.g., "http://example.com:*") + * @param allowedHosts List of allowed host patterns. Supports exact matches (e.g., + * "example.com:8080") and wildcard ports (e.g., "example.com:*") + */ + private DefaultServerTransportSecurityValidator(List allowedOrigins, List allowedHosts) { + Assert.notNull(allowedOrigins, "allowedOrigins must not be null"); + Assert.notNull(allowedHosts, "allowedHosts must not be null"); + this.allowedOrigins = allowedOrigins; + this.allowedHosts = allowedHosts; + } + + @Override + public void validateHeaders(Map> headers) throws ServerTransportSecurityException { + boolean missingHost = true; + for (Map.Entry> entry : headers.entrySet()) { + if (ORIGIN_HEADER.equalsIgnoreCase(entry.getKey())) { + List values = entry.getValue(); + if (values == null || values.isEmpty()) { + throw new ServerTransportSecurityException(403, "Invalid Origin header"); + } + validateOrigin(values.get(0)); + } + else if (HOST_HEADER.equalsIgnoreCase(entry.getKey())) { + missingHost = false; + List values = entry.getValue(); + if (values == null || values.isEmpty()) { + throw new ServerTransportSecurityException(421, "Invalid Host header"); + } + validateHost(values.get(0)); + } + } + if (!allowedHosts.isEmpty() && missingHost) { + throw new ServerTransportSecurityException(421, "Invalid Host header"); + } + } + + /** + * Validates a single origin value against the allowed origins. Subclasses can + * override this method to customize origin validation logic. + * @param origin The origin header value, or null if not present + * @throws ServerTransportSecurityException if the origin is not allowed + */ + protected void validateOrigin(String origin) throws ServerTransportSecurityException { + // Origin absent = no validation needed (same-origin request) + if (origin == null || origin.isBlank()) { + return; + } + + for (String allowed : allowedOrigins) { + if (allowed.equals(origin)) { + return; + } + else if (allowed.endsWith(":*")) { + // Wildcard port pattern: "http://example.com:*" + String baseOrigin = allowed.substring(0, allowed.length() - 2); + if (origin.equals(baseOrigin) || origin.startsWith(baseOrigin + ":")) { + return; + } + } + + } + + throw new ServerTransportSecurityException(403, "Invalid Origin header"); + } + + /** + * Validates a single host value against the allowed hosts. + * @param host The host header value, or null if not present + * @throws ServerTransportSecurityException if the host is not allowed + */ + private void validateHost(String host) throws ServerTransportSecurityException { + if (allowedHosts.isEmpty()) { + return; + } + + // Host is required + if (host == null || host.isBlank()) { + throw new ServerTransportSecurityException(421, "Invalid Host header"); + } + + for (String allowed : allowedHosts) { + if (allowed.equals(host)) { + return; + } + else if (allowed.endsWith(":*")) { + // Wildcard port pattern: "example.com:*" + String baseHost = allowed.substring(0, allowed.length() - 2); + if (host.equals(baseHost) || host.startsWith(baseHost + ":")) { + return; + } + } + } + + throw new ServerTransportSecurityException(421, "Invalid Host header"); + } + + /** + * Creates a new builder for constructing a DefaultServerTransportSecurityValidator. + * @return A new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for creating instances of {@link DefaultServerTransportSecurityValidator}. + */ + public static class Builder { + + private final List allowedOrigins = new ArrayList<>(); + + private final List allowedHosts = new ArrayList<>(); + + /** + * Adds an allowed origin pattern. + * @param origin The origin to allow (e.g., "http://localhost:8080" or + * "http://example.com:*") + * @return this builder instance + */ + public Builder allowedOrigin(String origin) { + this.allowedOrigins.add(origin); + return this; + } + + /** + * Adds multiple allowed origin patterns. + * @param origins The origins to allow + * @return this builder instance + */ + public Builder allowedOrigins(List origins) { + Assert.notNull(origins, "origins must not be null"); + this.allowedOrigins.addAll(origins); + return this; + } + + /** + * Adds an allowed host pattern. + * @param host The host to allow (e.g., "localhost:8080" or "example.com:*") + * @return this builder instance + */ + public Builder allowedHost(String host) { + this.allowedHosts.add(host); + return this; + } + + /** + * Adds multiple allowed host patterns. + * @param hosts The hosts to allow + * @return this builder instance + */ + public Builder allowedHosts(List hosts) { + Assert.notNull(hosts, "hosts must not be null"); + this.allowedHosts.addAll(hosts); + return this; + } + + /** + * Builds the validator instance. + * @return A new DefaultServerTransportSecurityValidator + */ + public DefaultServerTransportSecurityValidator build() { + return new DefaultServerTransportSecurityValidator(allowedOrigins, allowedHosts); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java new file mode 100644 index 000000000..32246948c --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Utility methods for working with {@link HttpServletRequest}. For internal use only. + * + * @author Daniel Garnier-Moiroux + */ +final class HttpServletRequestUtils { + + private HttpServletRequestUtils() { + } + + /** + * Extracts all headers from the HTTP request into a map. + * @param request The HTTP servlet request + * @return A map of header names to their values + */ + static Map> extractHeaders(HttpServletRequest request) { + Map> headers = new HashMap<>(); + Enumeration names = request.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + headers.put(name, Collections.list(request.getHeaders(name))); + } + return headers; + } + +} 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 96cebb74a..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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 - 2024 the original author or authors. + * Copyright 2024 - 2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicBoolean; 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; @@ -142,6 +143,11 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement */ private KeepAliveScheduler keepAliveScheduler; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + /** * Creates a new HttpServletSseServerTransportProvider instance with a custom SSE * endpoint. @@ -153,23 +159,25 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement * @param keepAliveInterval The interval for keep-alive pings, or null to disable * keep-alive functionality * @param contextExtractor The extractor for transport context from the request. - * @deprecated Use the builder {@link #builder()} instead for better configuration - * options. + * @param securityValidator The security validator for validating HTTP requests. */ private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint, String sseEndpoint, Duration keepAliveInterval, - McpTransportContextExtractor contextExtractor) { + McpTransportContextExtractor contextExtractor, + ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "JsonMapper must not be null"); Assert.notNull(messageEndpoint, "messageEndpoint must not be null"); Assert.notNull(sseEndpoint, "sseEndpoint 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; if (keepAliveInterval != null) { @@ -246,6 +254,15 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + response.setContentType("text/event-stream"); response.setCharacterEncoding(UTF_8); response.setHeader("Cache-Control", "no-cache"); @@ -311,13 +328,24 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + // Get the session ID from the request parameter String sessionId = request.getParameter("sessionId"); if (sessionId == null) { 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(); @@ -330,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(); @@ -357,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); @@ -547,6 +579,8 @@ public static class Builder { private Duration keepAliveInterval; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + /** * Sets the JsonMapper implementation to use for serialization/deserialization. If * not specified, a JacksonJsonMapper will be created from the configured @@ -621,6 +655,18 @@ public Builder keepAliveInterval(Duration 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 HttpServletSseServerTransportProvider with the * configured settings. @@ -632,8 +678,8 @@ public HttpServletSseServerTransportProvider build() { throw new IllegalStateException("MessageEndpoint must be set"); } return new HttpServletSseServerTransportProvider( - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, baseUrl, messageEndpoint, sseEndpoint, - keepAliveInterval, contextExtractor); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint, + sseEndpoint, keepAliveInterval, contextExtractor, securityValidator); } } 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 40767f416..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 @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -7,10 +7,13 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.PrintWriter; +import java.util.List; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.common.McpTransportContext; @@ -58,15 +61,23 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements private volatile boolean isClosing = false; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor contextExtractor) { + 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; } @Override @@ -122,12 +133,23 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + McpTransportContext transportContext = this.contextExtractor.extract(request); 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; } @@ -160,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) { @@ -173,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()); } } @@ -243,6 +274,8 @@ public static class Builder { private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + private Builder() { // used by a static method } @@ -288,6 +321,18 @@ public Builder contextExtractor(McpTransportContextExtractor 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 HttpServletStatelessServerTransport} with the * configured settings. @@ -296,8 +341,9 @@ public Builder contextExtractor(McpTransportContextExtractor */ public HttpServletStatelessServerTransport build() { Assert.notNull(mcpEndpoint, "Message endpoint must be set"); - return new HttpServletStatelessServerTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor); + return new HttpServletStatelessServerTransport( + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor, + securityValidator); } } 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 34671c105..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 @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server.transport; @@ -10,6 +10,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; @@ -28,6 +29,7 @@ import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.util.KeepAliveScheduler; import jakarta.servlet.AsyncContext; @@ -119,6 +121,11 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet */ private KeepAliveScheduler keepAliveScheduler; + /** + * Security validator for validating HTTP requests. + */ + private final ServerTransportSecurityValidator securityValidator; + /** * Constructs a new HttpServletStreamableServerTransportProvider instance. * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization of @@ -127,19 +134,24 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet * 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 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 HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, boolean disallowDelete, McpTransportContextExtractor contextExtractor, - Duration keepAliveInterval) { + Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator) { Assert.notNull(jsonMapper, "JsonMapper must not be null"); Assert.notNull(mcpEndpoint, "MCP 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.disallowDelete = disallowDelete; this.contextExtractor = contextExtractor; + this.securityValidator = securityValidator; if (keepAliveInterval != null) { @@ -157,7 +169,7 @@ private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, S @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_06_18, ProtocolVersions.MCP_2025_11_25); } @Override @@ -246,6 +258,15 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + List badRequestErrors = new ArrayList<>(); String accept = request.getHeader(ACCEPT); @@ -261,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; } @@ -373,6 +395,15 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } + try { + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + List badRequestErrors = new ArrayList<>(); String accept = request.getHeader(ACCEPT); @@ -400,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; } @@ -430,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; } } @@ -443,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; } @@ -451,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; } @@ -493,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()); @@ -536,6 +577,15 @@ protected void doDelete(HttpServletRequest request, HttpServletResponse response return; } + try { + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); + } + catch (ServerTransportSecurityException e) { + response.sendError(e.getStatusCode(), e.getMessage()); + return; + } + if (this.disallowDelete) { response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); return; @@ -545,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; } @@ -566,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()); @@ -774,6 +826,8 @@ public static class Builder { private Duration keepAliveInterval; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + /** * Sets the JsonMapper to use for JSON serialization/deserialization of MCP * messages. @@ -833,6 +887,18 @@ public Builder keepAliveInterval(Duration 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 HttpServletStreamableServerTransportProvider} * with the configured settings. @@ -842,8 +908,8 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { public HttpServletStreamableServerTransportProvider build() { Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new HttpServletStreamableServerTransportProvider( - jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete, + contextExtractor, keepAliveInterval, securityValidator); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityException.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityException.java new file mode 100644 index 000000000..96a06d3bd --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityException.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +/** + * Exception thrown when security validation fails for an HTTP request. Contains HTTP + * status code and message. + * + * @author Daniel Garnier-Moiroux + * @see ServerTransportSecurityValidator + */ +public class ServerTransportSecurityException extends Exception { + + private final int statusCode; + + /** + * Creates a new ServerTransportSecurityException with the specified HTTP status code + * and message. + */ + public ServerTransportSecurityException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ServerTransportSecurityException that = (ServerTransportSecurityException) obj; + return statusCode == that.statusCode && java.util.Objects.equals(getMessage(), that.getMessage()); + } + + @Override + public int hashCode() { + return java.util.Objects.hash(statusCode, getMessage()); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java new file mode 100644 index 000000000..ce805931f --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.List; +import java.util.Map; + +/** + * Interface for validating HTTP requests in server transports. Implementations can + * validate Origin headers, Host headers, or any other security-related headers according + * to the MCP specification. + * + * @author Daniel Garnier-Moiroux + * @see DefaultServerTransportSecurityValidator + * @see ServerTransportSecurityException + */ +@FunctionalInterface +public interface ServerTransportSecurityValidator { + + /** + * A no-op validator that accepts all requests without validation. + */ + ServerTransportSecurityValidator NOOP = headers -> { + }; + + /** + * Validates the HTTP headers from an incoming request. + * @param headers A map of header names to their values (multi-valued headers + * supported) + * @throws ServerTransportSecurityException if validation fails + */ + void validateHeaders(Map> headers) throws ServerTransportSecurityException; + +} 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 b58f1c552..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_06_18; - 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/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java index d1c2e5206..ee28f5ff8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStatelessServerTransport.java @@ -29,7 +29,8 @@ default void close() { Mono closeGracefully(); default List protocolVersions() { - return List.of(ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18); + return List.of(ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, + ProtocolVersions.MCP_2025_11_25); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/McpServiceLoader.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/McpServiceLoader.java new file mode 100644 index 000000000..f1c73a07a --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/McpServiceLoader.java @@ -0,0 +1,68 @@ +/** + * Copyright 2026 - 2026 the original author or authors. + */ +package io.modelcontextprotocol.util; + +import java.util.Optional; +import java.util.ServiceConfigurationError; +import java.util.ServiceLoader; +import java.util.function.Supplier; + +/** + * Instance of this class are intended to be used differently in OSGi and non-OSGi + * environments. In all non-OSGi environments the supplier member will be + * null and the serviceLoad method will be called to use the + * ServiceLoader.load to find the first instance of the supplier (assuming one is present + * in the runtime), cache it, and call the supplier's get method. + *

+ * In OSGi environments, the Service component runtime (scr) will call the setSupplier + * method upon bundle activation (assuming one is present in the runtime), and subsequent + * calls will use the given supplier instance rather than the ServiceLoader.load. + * + * @param the type of the supplier + * @param the type of the supplier result/returned value + */ +public class McpServiceLoader, R> { + + private Class supplierType; + + private S supplier; + + private R supplierResult; + + public void setSupplier(S supplier) { + this.supplier = supplier; + this.supplierResult = null; + } + + public void unsetSupplier(S supplier) { + this.supplier = null; + this.supplierResult = null; + } + + public McpServiceLoader(Class supplierType) { + this.supplierType = supplierType; + } + + protected Optional serviceLoad(Class type) { + return ServiceLoader.load(type).findFirst(); + } + + @SuppressWarnings("unchecked") + public synchronized R getDefault() { + if (this.supplierResult == null) { + if (this.supplier == null) { + // Use serviceloader + Optional sl = serviceLoad(this.supplierType); + if (sl.isEmpty()) { + throw new ServiceConfigurationError( + "No %s available for creating McpJsonMapper".formatted(this.supplierType.getSimpleName())); + } + this.supplier = (S) sl.get(); + } + this.supplierResult = this.supplier.get(); + } + return supplierResult; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolNameValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolNameValidator.java new file mode 100644 index 000000000..d7ac18705 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolNameValidator.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.util; + +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates tool names according to the MCP specification. + * + *

+ * Tool names must conform to the following rules: + *

    + *
  • Must be between 1 and 128 characters in length
  • + *
  • May only contain: A-Z, a-z, 0-9, underscore (_), hyphen (-), and dot (.)
  • + *
  • Must not contain spaces, commas, or other special characters
  • + *
+ * + * @see MCP + * Specification - Tool Names + * @author Andrei Shakirin + */ +public final class ToolNameValidator { + + private static final Logger logger = LoggerFactory.getLogger(ToolNameValidator.class); + + private static final int MAX_LENGTH = 128; + + private static final Pattern VALID_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-.]+$"); + + /** + * System property for strict tool name validation. Set to "false" to warn only + * instead of throwing exceptions. Default is true (strict). + */ + public static final String STRICT_VALIDATION_PROPERTY = "io.modelcontextprotocol.strictToolNameValidation"; + + private ToolNameValidator() { + } + + /** + * Returns the default strict validation setting from system property. + * @return true if strict validation is enabled (default), false if disabled via + * system property + */ + public static boolean isStrictByDefault() { + return !"false".equalsIgnoreCase(System.getProperty(STRICT_VALIDATION_PROPERTY)); + } + + /** + * Validates a tool name according to MCP specification. + * @param name the tool name to validate + * @param strict if true, throws exception on invalid name; if false, logs warning + * only + * @throws IllegalArgumentException if validation fails and strict is true + */ + public static void validate(String name, boolean strict) { + if (name == null || name.isEmpty()) { + handleError("Tool name must not be null or empty", name, strict); + } + else if (name.length() > MAX_LENGTH) { + handleError("Tool name must not exceed 128 characters", name, strict); + } + else if (!VALID_NAME_PATTERN.matcher(name).matches()) { + handleError("Tool name contains invalid characters (allowed: A-Z, a-z, 0-9, _, -, .)", name, strict); + } + } + + private static void handleError(String message, String name, boolean strict) { + String fullMessage = message + ": '" + name + "'"; + if (strict) { + throw new IllegalArgumentException(fullMessage); + } + else { + logger.warn("{}. Processing continues, but tool name should be fixed.", fullMessage); + } + } + +} diff --git a/mcp-core/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml b/mcp-core/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml new file mode 100644 index 000000000..1a10fdfb3 --- /dev/null +++ b/mcp-core/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java b/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java index 9854de210..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.McpJsonMapper; 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) -> { @@ -100,7 +100,7 @@ public Mono closeGracefully() { @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return McpJsonMapper.getDefault().convertValue(data, typeRef); + return (T) data; } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java deleted file mode 100644 index 183b8a365..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientResiliencyTests.java +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -import eu.rekawek.toxiproxy.Proxy; -import eu.rekawek.toxiproxy.ToxiproxyClient; -import eu.rekawek.toxiproxy.model.ToxicDirection; -import io.modelcontextprotocol.spec.McpClientTransport; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpTransport; -import io.modelcontextprotocol.spec.McpTransportSessionClosedException; -import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.Network; -import org.testcontainers.containers.ToxiproxyContainer; -import org.testcontainers.containers.wait.strategy.Wait; -import reactor.test.StepVerifier; - -import java.io.IOException; -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThatCode; - -/** - * Resiliency test suite for the {@link McpAsyncClient} that can be used with different - * {@link McpTransport} implementations that support Streamable HTTP. - * - * The purpose of these tests is to allow validating the transport layer resiliency - * instead of the functionality offered by the logical layer of MCP concepts such as - * tools, resources, prompts, etc. - * - * @author Dariusz Jędrzejczyk - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpAsyncClientResiliencyTests { - - private static final Logger logger = LoggerFactory.getLogger(AbstractMcpAsyncClientResiliencyTests.class); - - static Network network = Network.newNetwork(); - static String host = "http://localhost:3001"; - - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") - .withLogConsumer(outputFrame -> System.out.println(outputFrame.getUtf8String())) - .withNetwork(network) - .withNetworkAliases("everything-server") - .withExposedPorts(3001) - .waitingFor(Wait.forHttp("/").forStatusCode(404)); - - static ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0").withNetwork(network) - .withExposedPorts(8474, 3000); - - static Proxy proxy; - - static { - container.start(); - - toxiproxy.start(); - - final ToxiproxyClient toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort()); - try { - proxy = toxiproxyClient.createProxy("everything-server", "0.0.0.0:3000", "everything-server:3001"); - } - catch (IOException e) { - throw new RuntimeException("Can't create proxy!", e); - } - - final String ipAddressViaToxiproxy = toxiproxy.getHost(); - final int portViaToxiproxy = toxiproxy.getMappedPort(3000); - - host = "http://" + ipAddressViaToxiproxy + ":" + portViaToxiproxy; - } - - static void disconnect() { - long start = System.nanoTime(); - try { - // disconnect - // proxy.toxics().bandwidth("CUT_CONNECTION_DOWNSTREAM", - // ToxicDirection.DOWNSTREAM, 0); - // proxy.toxics().bandwidth("CUT_CONNECTION_UPSTREAM", - // ToxicDirection.UPSTREAM, 0); - proxy.toxics().resetPeer("RESET_DOWNSTREAM", ToxicDirection.DOWNSTREAM, 0); - proxy.toxics().resetPeer("RESET_UPSTREAM", ToxicDirection.UPSTREAM, 0); - logger.info("Disconnect took {} ms", Duration.ofNanos(System.nanoTime() - start).toMillis()); - } - catch (IOException e) { - throw new RuntimeException("Failed to disconnect", e); - } - } - - static void reconnect() { - long start = System.nanoTime(); - try { - proxy.toxics().get("RESET_UPSTREAM").remove(); - proxy.toxics().get("RESET_DOWNSTREAM").remove(); - // proxy.toxics().get("CUT_CONNECTION_DOWNSTREAM").remove(); - // proxy.toxics().get("CUT_CONNECTION_UPSTREAM").remove(); - logger.info("Reconnect took {} ms", Duration.ofNanos(System.nanoTime() - start).toMillis()); - } - catch (IOException e) { - throw new RuntimeException("Failed to reconnect", e); - } - } - - static void restartMcpServer() { - container.stop(); - container.start(); - } - - abstract McpClientTransport createMcpTransport(); - - protected Duration getRequestTimeout() { - return Duration.ofSeconds(14); - } - - protected Duration getInitializationTimeout() { - return Duration.ofSeconds(2); - } - - McpAsyncClient client(McpClientTransport transport) { - return client(transport, Function.identity()); - } - - McpAsyncClient client(McpClientTransport transport, Function customizer) { - AtomicReference client = new AtomicReference<>(); - - assertThatCode(() -> { - // Do not advertise roots. Otherwise, the server will list roots during - // initialization. The client responds asynchronously, and there might be a - // rest condition in tests where we disconnect right after initialization. - McpClient.AsyncSpec builder = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .capabilities(McpSchema.ClientCapabilities.builder().build()); - builder = customizer.apply(builder); - client.set(builder.build()); - }).doesNotThrowAnyException(); - - return client.get(); - } - - void withClient(McpClientTransport transport, Consumer c) { - withClient(transport, Function.identity(), c); - } - - void withClient(McpClientTransport transport, Function customizer, - Consumer c) { - var client = client(transport, customizer); - try { - c.accept(client); - } - finally { - StepVerifier.create(client.closeGracefully()).expectComplete().verify(Duration.ofSeconds(10)); - } - } - - @Test - void testPing() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); - - disconnect(); - - StepVerifier.create(mcpAsyncClient.ping()).expectError().verify(); - - reconnect(); - - StepVerifier.create(mcpAsyncClient.ping()).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testSessionInvalidation() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); - - restartMcpServer(); - - // The first try will face the session mismatch exception and the second one - // will go through the re-initialization process. - StepVerifier.create(mcpAsyncClient.ping().retry(1)).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testCallTool() { - withClient(createMcpTransport(), mcpAsyncClient -> { - AtomicReference> tools = new AtomicReference<>(); - StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); - StepVerifier.create(mcpAsyncClient.listTools()) - .consumeNextWith(list -> tools.set(list.tools())) - .verifyComplete(); - - disconnect(); - - String name = tools.get().get(0).name(); - // Assuming this is the echo tool - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(name, Map.of("message", "hello")); - StepVerifier.create(mcpAsyncClient.callTool(request)).expectError().verify(); - - reconnect(); - - StepVerifier.create(mcpAsyncClient.callTool(request)).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testSessionClose() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize()).expectNextCount(1).verifyComplete(); - // In case of Streamable HTTP this call should issue a HTTP DELETE request - // invalidating the session - StepVerifier.create(mcpAsyncClient.closeGracefully()).expectComplete().verify(); - // The next tries to use the closed session and fails - StepVerifier.create(mcpAsyncClient.ping()) - .expectErrorMatches(err -> err.getCause() instanceof McpTransportSessionClosedException) - .verify(); - }); - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java deleted file mode 100644 index 57a223ea2..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ /dev/null @@ -1,823 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -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; -import static org.assertj.core.api.Assertions.fail; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import io.modelcontextprotocol.spec.McpClientTransport; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; -import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; -import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; -import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; -import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; -import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; -import io.modelcontextprotocol.spec.McpSchema.ElicitResult; -import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; -import io.modelcontextprotocol.spec.McpSchema.Prompt; -import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; -import io.modelcontextprotocol.spec.McpSchema.Resource; -import io.modelcontextprotocol.spec.McpSchema.ResourceContents; -import io.modelcontextprotocol.spec.McpSchema.Root; -import io.modelcontextprotocol.spec.McpSchema.SubscribeRequest; -import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; -import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest; -import io.modelcontextprotocol.spec.McpTransport; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; -import reactor.test.StepVerifier; - -/** - * Test suite for the {@link McpAsyncClient} that can be used with different - * {@link McpTransport} implementations. - * - * @author Christian Tzolov - * @author Dariusz Jędrzejczyk - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpAsyncClientTests { - - private static final String ECHO_TEST_MESSAGE = "Hello MCP Spring AI!"; - - abstract protected McpClientTransport createMcpTransport(); - - protected Duration getRequestTimeout() { - return Duration.ofSeconds(14); - } - - protected Duration getInitializationTimeout() { - return Duration.ofSeconds(2); - } - - McpAsyncClient client(McpClientTransport transport) { - return client(transport, Function.identity()); - } - - McpAsyncClient client(McpClientTransport transport, Function customizer) { - AtomicReference client = new AtomicReference<>(); - - assertThatCode(() -> { - McpClient.AsyncSpec builder = McpClient.async(transport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .sampling(req -> Mono.just(new CreateMessageResult(McpSchema.Role.USER, - new McpSchema.TextContent("Oh, hi!"), "modelId", CreateMessageResult.StopReason.END_TURN))) - .capabilities(ClientCapabilities.builder().roots(true).sampling().build()); - builder = customizer.apply(builder); - client.set(builder.build()); - }).doesNotThrowAnyException(); - - return client.get(); - } - - void withClient(McpClientTransport transport, Consumer c) { - withClient(transport, Function.identity(), c); - } - - void withClient(McpClientTransport transport, Function customizer, - Consumer c) { - var client = client(transport, customizer); - try { - c.accept(client); - } - finally { - StepVerifier.create(client.closeGracefully()).expectComplete().verify(Duration.ofSeconds(10)); - } - } - - void verifyNotificationSucceedsWithImplicitInitialization(Function> operation, - String action) { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(operation.apply(mcpAsyncClient)).verifyComplete(); - }); - } - - void verifyCallSucceedsWithImplicitInitialization(Function> operation, String action) { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(operation.apply(mcpAsyncClient)).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.async(null).build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Transport must not be null"); - - assertThatThrownBy(() -> McpClient.async(createMcpTransport()).requestTimeout(null).build()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Request timeout must not be null"); - } - - @Test - void testListToolsWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listTools(McpSchema.FIRST_PAGE), "listing tools"); - } - - @Test - void testListTools() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools(McpSchema.FIRST_PAGE))) - .consumeNextWith(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllTools() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools())) - .consumeNextWith(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllToolsReturnsImmutableList() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listTools())) - .consumeNextWith(result -> { - assertThat(result.tools()).isNotNull(); - // Verify that the returned list is immutable - assertThatThrownBy(() -> result.tools() - .add(Tool.builder() - .name("test") - .title("test") - .inputSchema(JSON_MAPPER, "{\"type\":\"object\"}") - .build())) - .isInstanceOf(UnsupportedOperationException.class); - }) - .verifyComplete(); - }); - } - - @Test - void testPingWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.ping(), "pinging the server"); - } - - @Test - void testPing() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.ping())) - .expectNextCount(1) - .verifyComplete(); - }); - } - - @Test - void testCallToolWithoutInitialization() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - verifyCallSucceedsWithImplicitInitialization(client -> client.callTool(callToolRequest), "calling tools"); - } - - @Test - void testCallTool() { - withClient(createMcpTransport(), mcpAsyncClient -> { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", ECHO_TEST_MESSAGE)); - - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(callToolRequest))) - .consumeNextWith(callToolResult -> { - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); - }); - }) - .verifyComplete(); - }); - } - - @Test - void testCallToolWithInvalidTool() { - withClient(createMcpTransport(), mcpAsyncClient -> { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", - Map.of("message", ECHO_TEST_MESSAGE)); - - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.callTool(invalidRequest))) - .consumeErrorWith( - e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Unknown tool: nonexistent_tool")) - .verify(); - }); - } - - @ParameterizedTest - @ValueSource(strings = { "success", "error", "debug" }) - void testCallToolWithMessageAnnotations(String messageType) { - McpClientTransport transport = createMcpTransport(); - - withClient(transport, mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize() - .then(mcpAsyncClient.callTool(new McpSchema.CallToolRequest("annotatedMessage", - Map.of("messageType", messageType, "includeImage", true))))) - .consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.isError()).isNotEqualTo(true); - assertThat(result.content()).isNotEmpty(); - assertThat(result.content()).allSatisfy(content -> { - switch (content.type()) { - case "text": - McpSchema.TextContent textContent = assertInstanceOf(McpSchema.TextContent.class, - content); - assertThat(textContent.text()).isNotEmpty(); - assertThat(textContent.annotations()).isNotNull(); - - switch (messageType) { - case "error": - assertThat(textContent.annotations().priority()).isEqualTo(1.0); - assertThat(textContent.annotations().audience()) - .containsOnly(McpSchema.Role.USER, McpSchema.Role.ASSISTANT); - break; - case "success": - assertThat(textContent.annotations().priority()).isEqualTo(0.7); - assertThat(textContent.annotations().audience()) - .containsExactly(McpSchema.Role.USER); - break; - case "debug": - assertThat(textContent.annotations().priority()).isEqualTo(0.3); - assertThat(textContent.annotations().audience()) - .containsExactly(McpSchema.Role.ASSISTANT); - break; - default: - throw new IllegalStateException("Unexpected value: " + content.type()); - } - break; - case "image": - McpSchema.ImageContent imageContent = assertInstanceOf(McpSchema.ImageContent.class, - content); - assertThat(imageContent.data()).isNotEmpty(); - assertThat(imageContent.annotations()).isNotNull(); - assertThat(imageContent.annotations().priority()).isEqualTo(0.5); - assertThat(imageContent.annotations().audience()).containsExactly(McpSchema.Role.USER); - break; - default: - fail("Unexpected content type: " + content.type()); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListResourcesWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listResources(McpSchema.FIRST_PAGE), - "listing resources"); - } - - @Test - void testListResources() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources(McpSchema.FIRST_PAGE))) - .consumeNextWith(resources -> { - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllResources() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources())) - .consumeNextWith(resources -> { - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllResourcesReturnsImmutableList() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResources())) - .consumeNextWith(result -> { - assertThat(result.resources()).isNotNull(); - // Verify that the returned list is immutable - assertThatThrownBy( - () -> result.resources().add(Resource.builder().uri("test://uri").name("test").build())) - .isInstanceOf(UnsupportedOperationException.class); - }) - .verifyComplete(); - }); - } - - @Test - void testMcpAsyncClientState() { - withClient(createMcpTransport(), mcpAsyncClient -> { - assertThat(mcpAsyncClient).isNotNull(); - }); - } - - @Test - void testListPromptsWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listPrompts(McpSchema.FIRST_PAGE), - "listing " + "prompts"); - } - - @Test - void testListPrompts() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts(McpSchema.FIRST_PAGE))) - .consumeNextWith(prompts -> { - assertThat(prompts).isNotNull().satisfies(result -> { - assertThat(result.prompts()).isNotNull(); - - if (!result.prompts().isEmpty()) { - Prompt firstPrompt = result.prompts().get(0); - assertThat(firstPrompt.name()).isNotNull(); - assertThat(firstPrompt.description()).isNotNull(); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllPrompts() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts())) - .consumeNextWith(prompts -> { - assertThat(prompts).isNotNull().satisfies(result -> { - assertThat(result.prompts()).isNotNull(); - - if (!result.prompts().isEmpty()) { - Prompt firstPrompt = result.prompts().get(0); - assertThat(firstPrompt.name()).isNotNull(); - assertThat(firstPrompt.description()).isNotNull(); - } - }); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllPromptsReturnsImmutableList() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listPrompts())) - .consumeNextWith(result -> { - assertThat(result.prompts()).isNotNull(); - // Verify that the returned list is immutable - assertThatThrownBy(() -> result.prompts().add(new Prompt("test", "test", "test", null))) - .isInstanceOf(UnsupportedOperationException.class); - }) - .verifyComplete(); - }); - } - - @Test - void testGetPromptWithoutInitialization() { - GetPromptRequest request = new GetPromptRequest("simple_prompt", Map.of()); - verifyCallSucceedsWithImplicitInitialization(client -> client.getPrompt(request), "getting " + "prompts"); - } - - @Test - void testGetPrompt() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier - .create(mcpAsyncClient.initialize() - .then(mcpAsyncClient.getPrompt(new GetPromptRequest("simple_prompt", Map.of())))) - .consumeNextWith(prompt -> { - assertThat(prompt).isNotNull().satisfies(result -> { - assertThat(result.messages()).isNotEmpty(); - assertThat(result.messages()).hasSize(1); - }); - }) - .verifyComplete(); - }); - } - - @Test - void testRootsListChangedWithoutInitialization() { - verifyNotificationSucceedsWithImplicitInitialization(client -> client.rootsListChangedNotification(), - "sending roots list changed notification"); - } - - @Test - void testRootsListChanged() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.rootsListChangedNotification())) - .verifyComplete(); - }); - } - - @Test - void testInitializeWithRootsListProviders() { - withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), - client -> { - StepVerifier.create(client.initialize().then(client.closeGracefully())).verifyComplete(); - }); - } - - @Test - void testAddRoot() { - withClient(createMcpTransport(), mcpAsyncClient -> { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); - StepVerifier.create(mcpAsyncClient.addRoot(newRoot)).verifyComplete(); - }); - } - - @Test - void testAddRootWithNullValue() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.addRoot(null)) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Root must not be null")) - .verify(); - }); - } - - @Test - void testRemoveRoot() { - withClient(createMcpTransport(), mcpAsyncClient -> { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); - StepVerifier.create(mcpAsyncClient.addRoot(root)).verifyComplete(); - - StepVerifier.create(mcpAsyncClient.removeRoot(root.uri())).verifyComplete(); - }); - } - - @Test - void testRemoveNonExistentRoot() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri")) - .consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalStateException.class) - .hasMessage("Root with uri 'nonexistent-uri' not found")) - .verify(); - }); - } - - @Test - void testReadResource() { - withClient(createMcpTransport(), client -> { - Flux resources = client.initialize() - .then(client.listResources(null)) - .flatMapMany(r -> Flux.fromIterable(r.resources())) - .flatMap(r -> client.readResource(r)); - - StepVerifier.create(resources).recordWith(ArrayList::new).consumeRecordedWith(readResourceResults -> { - - for (ReadResourceResult result : readResourceResults) { - - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull().isNotEmpty(); - - // Validate each content item - for (ResourceContents content : result.contents()) { - assertThat(content).isNotNull(); - assertThat(content.uri()).isNotNull().isNotEmpty(); - assertThat(content.mimeType()).isNotNull().isNotEmpty(); - - // Validate content based on its type with more comprehensive - // checks - switch (content.mimeType()) { - case "text/plain" -> { - TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, - content); - assertThat(textContent.text()).isNotNull().isNotEmpty(); - assertThat(textContent.uri()).isNotEmpty(); - } - case "application/octet-stream" -> { - BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, - content); - assertThat(blobContent.blob()).isNotNull().isNotEmpty(); - assertThat(blobContent.uri()).isNotNull().isNotEmpty(); - // Validate base64 encoding format - assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); - } - default -> { - - // Still validate basic properties - if (content instanceof TextResourceContents textContent) { - assertThat(textContent.text()).isNotNull(); - } - else if (content instanceof BlobResourceContents blobContent) { - assertThat(blobContent.blob()).isNotNull(); - } - } - } - } - } - }) - .expectNextCount(10) // Expect 10 elements - .verifyComplete(); - }); - } - - @Test - void testListResourceTemplatesWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listResourceTemplates(McpSchema.FIRST_PAGE), - "listing resource templates"); - } - - @Test - void testListResourceTemplates() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier - .create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates(McpSchema.FIRST_PAGE))) - .consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllResourceTemplates() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) - .consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }) - .verifyComplete(); - }); - } - - @Test - void testListAllResourceTemplatesReturnsImmutableList() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize().then(mcpAsyncClient.listResourceTemplates())) - .consumeNextWith(result -> { - assertThat(result.resourceTemplates()).isNotNull(); - // Verify that the returned list is immutable - assertThatThrownBy(() -> result.resourceTemplates() - .add(new McpSchema.ResourceTemplate("test://template", "test", "test", null, null, null))) - .isInstanceOf(UnsupportedOperationException.class); - }) - .verifyComplete(); - }); - } - - // @Test - void testResourceSubscription() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); - - // Test subscribe - StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) - .verifyComplete(); - - // Test unsubscribe - StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) - .verifyComplete(); - } - }).verifyComplete(); - }); - } - - @Test - void testNotificationHandlers() { - AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false); - AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); - AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); - - withClient(createMcpTransport(), - builder -> builder - .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> toolsNotificationReceived.set(true))) - .resourcesChangeConsumer( - resources -> Mono.fromRunnable(() -> resourcesNotificationReceived.set(true))) - .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> promptsNotificationReceived.set(true))), - mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.initialize()) - .expectNextMatches(Objects::nonNull) - .verifyComplete(); - }); - } - - @Test - void testInitializeWithSamplingCapability() { - ClientCapabilities capabilities = ClientCapabilities.builder().sampling().build(); - CreateMessageResult createMessageResult = CreateMessageResult.builder() - .message("test") - .model("test-model") - .build(); - withClient(createMcpTransport(), - builder -> builder.capabilities(capabilities).sampling(request -> Mono.just(createMessageResult)), - client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - }); - } - - @Test - void testInitializeWithElicitationCapability() { - ClientCapabilities capabilities = ClientCapabilities.builder().elicitation().build(); - ElicitResult elicitResult = ElicitResult.builder() - .message(ElicitResult.Action.ACCEPT) - .content(Map.of("foo", "bar")) - .build(); - withClient(createMcpTransport(), - builder -> builder.capabilities(capabilities).elicitation(request -> Mono.just(elicitResult)), - client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - }); - } - - @Test - void testInitializeWithAllCapabilities() { - var capabilities = ClientCapabilities.builder() - .experimental(Map.of("feature", Map.of("featureFlag", true))) - .roots(true) - .sampling() - .build(); - - Function> samplingHandler = request -> Mono - .just(CreateMessageResult.builder().message("test").model("test-model").build()); - - Function> elicitationHandler = request -> Mono - .just(ElicitResult.builder().message(ElicitResult.Action.ACCEPT).content(Map.of("foo", "bar")).build()); - - withClient(createMcpTransport(), - builder -> builder.capabilities(capabilities).sampling(samplingHandler).elicitation(elicitationHandler), - client -> - - StepVerifier.create(client.initialize()).assertNext(result -> { - assertThat(result).isNotNull(); - assertThat(result.capabilities()).isNotNull(); - }).verifyComplete()); - } - - // --------------------------------------- - // Logging Tests - // --------------------------------------- - - @Test - void testLoggingLevelsWithoutInitialization() { - verifyNotificationSucceedsWithImplicitInitialization( - client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), "setting logging level"); - } - - @Test - void testLoggingLevels() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier - .create(mcpAsyncClient.initialize() - .thenMany(Flux.fromArray(McpSchema.LoggingLevel.values()).flatMap(mcpAsyncClient::setLoggingLevel))) - .verifyComplete(); - }); - } - - @Test - void testLoggingConsumer() { - AtomicBoolean logReceived = new AtomicBoolean(false); - - withClient(createMcpTransport(), - builder -> builder.loggingConsumer(notification -> Mono.fromRunnable(() -> logReceived.set(true))), - client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - StepVerifier.create(client.closeGracefully()).verifyComplete(); - - }); - - } - - @Test - void testLoggingWithNullNotification() { - withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.setLoggingLevel(null)) - .expectErrorMatches(error -> error.getMessage().contains("Logging level must not be null")) - .verify(); - }); - } - - @Test - void testSampling() { - McpClientTransport transport = createMcpTransport(); - - final String message = "Hello, world!"; - final String response = "Goodbye, world!"; - final int maxTokens = 100; - - AtomicReference receivedPrompt = new AtomicReference<>(); - AtomicReference receivedMessage = new AtomicReference<>(); - AtomicInteger receivedMaxTokens = new AtomicInteger(); - - withClient(transport, spec -> spec.capabilities(McpSchema.ClientCapabilities.builder().sampling().build()) - .sampling(request -> { - McpSchema.TextContent messageText = assertInstanceOf(McpSchema.TextContent.class, - request.messages().get(0).content()); - receivedPrompt.set(request.systemPrompt()); - receivedMessage.set(messageText.text()); - receivedMaxTokens.set(request.maxTokens()); - - return Mono - .just(new McpSchema.CreateMessageResult(McpSchema.Role.USER, new McpSchema.TextContent(response), - "modelId", McpSchema.CreateMessageResult.StopReason.END_TURN)); - }), client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - - StepVerifier.create(client.callTool( - new McpSchema.CallToolRequest("sampleLLM", Map.of("prompt", message, "maxTokens", maxTokens)))) - .consumeNextWith(result -> { - // Verify tool response to ensure our sampling response was passed - // through - assertThat(result.content()).hasAtLeastOneElementOfType(McpSchema.TextContent.class); - assertThat(result.content()).allSatisfy(content -> { - if (!(content instanceof McpSchema.TextContent text)) - return; - - assertThat(text.text()).endsWith(response); // Prefixed - }); - - // Verify sampling request parameters received in our callback - assertThat(receivedPrompt.get()).isNotEmpty(); - assertThat(receivedMessage.get()).endsWith(message); // Prefixed - assertThat(receivedMaxTokens.get()).isEqualTo(maxTokens); - }) - .verifyComplete(); - }); - } - - // --------------------------------------- - // Progress Notification Tests - // --------------------------------------- - - @Test - void testProgressConsumer() { - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - List receivedNotifications = new CopyOnWriteArrayList<>(); - - withClient(createMcpTransport(), builder -> builder.progressConsumer(notification -> { - receivedNotifications.add(notification); - sink.tryEmitNext(notification); - return Mono.empty(); - }), client -> { - StepVerifier.create(client.initialize()).expectNextMatches(Objects::nonNull).verifyComplete(); - - // Call a tool that sends progress notifications - CallToolRequest request = CallToolRequest.builder() - .name("longRunningOperation") - .arguments(Map.of("duration", 1, "steps", 2)) - .progressToken("test-token") - .build(); - - StepVerifier.create(client.callTool(request)).consumeNextWith(result -> { - assertThat(result).isNotNull(); - }).verifyComplete(); - - // Use StepVerifier to verify the progress notifications via the sink - StepVerifier.create(sink.asFlux()).expectNextCount(2).thenCancel().verify(Duration.ofSeconds(3)); - - assertThat(receivedNotifications).hasSize(2); - assertThat(receivedNotifications.get(0).progressToken()).isEqualTo("test-token"); - }); - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java deleted file mode 100644 index 7ce12772c..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ /dev/null @@ -1,682 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.client; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.fail; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; - -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.modelcontextprotocol.spec.McpClientTransport; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; -import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; -import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; -import io.modelcontextprotocol.spec.McpSchema.ListResourceTemplatesResult; -import io.modelcontextprotocol.spec.McpSchema.ListResourcesResult; -import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; -import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; -import io.modelcontextprotocol.spec.McpSchema.Resource; -import io.modelcontextprotocol.spec.McpSchema.ResourceContents; -import io.modelcontextprotocol.spec.McpSchema.Root; -import io.modelcontextprotocol.spec.McpSchema.SubscribeRequest; -import io.modelcontextprotocol.spec.McpSchema.TextContent; -import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; -import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; -import reactor.test.StepVerifier; - -/** - * Unit tests for MCP Client Session functionality. - * - * @author Christian Tzolov - * @author Dariusz Jędrzejczyk - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpSyncClientTests { - - private static final Logger logger = LoggerFactory.getLogger(AbstractMcpSyncClientTests.class); - - private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; - - abstract protected McpClientTransport createMcpTransport(); - - protected Duration getRequestTimeout() { - return Duration.ofSeconds(14); - } - - protected Duration getInitializationTimeout() { - return Duration.ofSeconds(2); - } - - McpSyncClient client(McpClientTransport transport) { - return client(transport, Function.identity()); - } - - McpSyncClient client(McpClientTransport transport, Function customizer) { - AtomicReference client = new AtomicReference<>(); - - assertThatCode(() -> { - McpClient.SyncSpec builder = McpClient.sync(transport) - .requestTimeout(getRequestTimeout()) - .initializationTimeout(getInitializationTimeout()) - .capabilities(ClientCapabilities.builder().roots(true).build()); - builder = customizer.apply(builder); - client.set(builder.build()); - }).doesNotThrowAnyException(); - - return client.get(); - } - - void withClient(McpClientTransport transport, Consumer c) { - withClient(transport, Function.identity(), c); - } - - void withClient(McpClientTransport transport, Function customizer, - Consumer c) { - var client = client(transport, customizer); - try { - c.accept(client); - } - finally { - assertThat(client.closeGracefully()).isTrue(); - } - } - - static final Object DUMMY_RETURN_VALUE = new Object(); - - void verifyNotificationSucceedsWithImplicitInitialization(Consumer operation, String action) { - verifyCallSucceedsWithImplicitInitialization(client -> { - operation.accept(client); - return DUMMY_RETURN_VALUE; - }, action); - } - - void verifyCallSucceedsWithImplicitInitialization(Function blockingOperation, String action) { - withClient(createMcpTransport(), mcpSyncClient -> { - StepVerifier.create(Mono.fromSupplier(() -> blockingOperation.apply(mcpSyncClient)) - // Offload the blocking call to the real scheduler - .subscribeOn(Schedulers.boundedElastic())).expectNextCount(1).verifyComplete(); - }); - } - - @Test - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.sync(null).build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Transport must not be null"); - - assertThatThrownBy(() -> McpClient.sync(createMcpTransport()).requestTimeout(null).build()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Request timeout must not be null"); - } - - @Test - void testListToolsWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listTools(McpSchema.FIRST_PAGE), "listing tools"); - } - - @Test - void testListTools() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListToolsResult tools = mcpSyncClient.listTools(McpSchema.FIRST_PAGE); - - assertThat(tools).isNotNull().satisfies(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }); - }); - } - - @Test - void testListAllTools() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListToolsResult tools = mcpSyncClient.listTools(); - - assertThat(tools).isNotNull().satisfies(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }); - }); - } - - @Test - void testCallToolsWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization( - client -> client.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))), "calling tools"); - } - - @Test - void testCallTools() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - CallToolResult toolResult = mcpSyncClient.callTool(new CallToolRequest("add", Map.of("a", 3, "b", 4))); - - assertThat(toolResult).isNotNull().satisfies(result -> { - - assertThat(result.content()).hasSize(1); - - TextContent content = (TextContent) result.content().get(0); - - assertThat(content).isNotNull(); - assertThat(content.text()).isNotNull(); - assertThat(content.text()).contains("7"); - }); - }); - } - - @Test - void testPingWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.ping(), "pinging the server"); - } - - @Test - void testPing() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); - }); - } - - @Test - void testCallToolWithoutInitialization() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - verifyCallSucceedsWithImplicitInitialization(client -> client.callTool(callToolRequest), "calling tools"); - } - - @Test - void testCallTool() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - - CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); - - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); - }); - }); - } - - @Test - void testCallToolWithInvalidTool() { - withClient(createMcpTransport(), mcpSyncClient -> { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); - - assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); - }); - } - - @ParameterizedTest - @ValueSource(strings = { "success", "error", "debug" }) - void testCallToolWithMessageAnnotations(String messageType) { - McpClientTransport transport = createMcpTransport(); - - withClient(transport, client -> { - client.initialize(); - - McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest("annotatedMessage", - Map.of("messageType", messageType, "includeImage", true))); - - assertThat(result).isNotNull(); - assertThat(result.isError()).isNotEqualTo(true); - assertThat(result.content()).isNotEmpty(); - assertThat(result.content()).allSatisfy(content -> { - switch (content.type()) { - case "text": - McpSchema.TextContent textContent = assertInstanceOf(McpSchema.TextContent.class, content); - assertThat(textContent.text()).isNotEmpty(); - assertThat(textContent.annotations()).isNotNull(); - - switch (messageType) { - case "error": - assertThat(textContent.annotations().priority()).isEqualTo(1.0); - assertThat(textContent.annotations().audience()).containsOnly(McpSchema.Role.USER, - McpSchema.Role.ASSISTANT); - break; - case "success": - assertThat(textContent.annotations().priority()).isEqualTo(0.7); - assertThat(textContent.annotations().audience()).containsExactly(McpSchema.Role.USER); - break; - case "debug": - assertThat(textContent.annotations().priority()).isEqualTo(0.3); - assertThat(textContent.annotations().audience()) - .containsExactly(McpSchema.Role.ASSISTANT); - break; - default: - throw new IllegalStateException("Unexpected value: " + content.type()); - } - break; - case "image": - McpSchema.ImageContent imageContent = assertInstanceOf(McpSchema.ImageContent.class, content); - assertThat(imageContent.data()).isNotEmpty(); - assertThat(imageContent.annotations()).isNotNull(); - assertThat(imageContent.annotations().priority()).isEqualTo(0.5); - assertThat(imageContent.annotations().audience()).containsExactly(McpSchema.Role.USER); - break; - default: - fail("Unexpected content type: " + content.type()); - } - }); - }); - } - - @Test - void testRootsListChangedWithoutInitialization() { - verifyNotificationSucceedsWithImplicitInitialization(client -> client.rootsListChangedNotification(), - "sending roots list changed notification"); - } - - @Test - void testRootsListChanged() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - assertThatCode(() -> mcpSyncClient.rootsListChangedNotification()).doesNotThrowAnyException(); - }); - } - - @Test - void testListResourcesWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listResources(McpSchema.FIRST_PAGE), - "listing resources"); - } - - @Test - void testListResources() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(McpSchema.FIRST_PAGE); - - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }); - } - - @Test - void testListAllResources() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(); - - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }); - } - - @Test - void testClientSessionState() { - withClient(createMcpTransport(), mcpSyncClient -> { - assertThat(mcpSyncClient).isNotNull(); - }); - } - - @Test - void testInitializeWithRootsListProviders() { - withClient(createMcpTransport(), builder -> builder.roots(new Root("file:///test/path", "test-root")), - mcpSyncClient -> { - - assertThatCode(() -> { - mcpSyncClient.initialize(); - mcpSyncClient.close(); - }).doesNotThrowAnyException(); - }); - } - - @Test - void testAddRoot() { - withClient(createMcpTransport(), mcpSyncClient -> { - Root newRoot = new Root("file:///new/test/path", "new-test-root"); - assertThatCode(() -> mcpSyncClient.addRoot(newRoot)).doesNotThrowAnyException(); - }); - } - - @Test - void testAddRootWithNullValue() { - withClient(createMcpTransport(), mcpSyncClient -> { - assertThatThrownBy(() -> mcpSyncClient.addRoot(null)).hasMessageContaining("Root must not be null"); - }); - } - - @Test - void testRemoveRoot() { - withClient(createMcpTransport(), mcpSyncClient -> { - Root root = new Root("file:///test/path/to/remove", "root-to-remove"); - assertThatCode(() -> { - mcpSyncClient.addRoot(root); - mcpSyncClient.removeRoot(root.uri()); - }).doesNotThrowAnyException(); - }); - } - - @Test - void testRemoveNonExistentRoot() { - withClient(createMcpTransport(), mcpSyncClient -> { - assertThatThrownBy(() -> mcpSyncClient.removeRoot("nonexistent-uri")) - .hasMessageContaining("Root with uri 'nonexistent-uri' not found"); - }); - } - - @Test - void testReadResourceWithoutInitialization() { - AtomicReference> resources = new AtomicReference<>(); - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - resources.set(mcpSyncClient.listResources().resources()); - }); - - verifyCallSucceedsWithImplicitInitialization(client -> client.readResource(resources.get().get(0)), - "reading resources"); - } - - @Test - void testReadResource() { - withClient(createMcpTransport(), mcpSyncClient -> { - - int readResourceCount = 0; - - mcpSyncClient.initialize(); - ListResourcesResult resources = mcpSyncClient.listResources(null); - - assertThat(resources).isNotNull(); - assertThat(resources.resources()).isNotNull(); - - assertThat(resources.resources()).isNotNull().isNotEmpty(); - - // Test reading each resource individually for better error isolation - for (Resource resource : resources.resources()) { - ReadResourceResult result = mcpSyncClient.readResource(resource); - - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull().isNotEmpty(); - - readResourceCount++; - - // Validate each content item - for (ResourceContents content : result.contents()) { - assertThat(content).isNotNull(); - assertThat(content.uri()).isNotNull().isNotEmpty(); - assertThat(content.mimeType()).isNotNull().isNotEmpty(); - - // Validate content based on its type with more comprehensive - // checks - switch (content.mimeType()) { - case "text/plain" -> { - TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, content); - assertThat(textContent.text()).isNotNull().isNotEmpty(); - // Verify URI consistency - assertThat(textContent.uri()).isEqualTo(resource.uri()); - } - case "application/octet-stream" -> { - BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, content); - assertThat(blobContent.blob()).isNotNull().isNotEmpty(); - // Verify URI consistency - assertThat(blobContent.uri()).isEqualTo(resource.uri()); - // Validate base64 encoding format - assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); - } - default -> { - // More flexible handling of additional MIME types - // Log the unexpected type for debugging but don't fail - // the test - logger.warn("Warning: Encountered unexpected MIME type: {} for resource: {}", - content.mimeType(), resource.uri()); - - // Still validate basic properties - if (content instanceof TextResourceContents textContent) { - assertThat(textContent.text()).isNotNull(); - } - else if (content instanceof BlobResourceContents blobContent) { - assertThat(blobContent.blob()).isNotNull(); - } - } - } - } - } - - // Assert that we read exactly 10 resources - assertThat(readResourceCount).isEqualTo(10); - }); - } - - @Test - void testListResourceTemplatesWithoutInitialization() { - verifyCallSucceedsWithImplicitInitialization(client -> client.listResourceTemplates(McpSchema.FIRST_PAGE), - "listing resource templates"); - } - - @Test - void testListResourceTemplates() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(McpSchema.FIRST_PAGE); - - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }); - } - - @Test - void testListAllResourceTemplates() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(); - - assertThat(result).isNotNull(); - assertThat(result.resourceTemplates()).isNotNull(); - }); - } - - // @Test - void testResourceSubscription() { - withClient(createMcpTransport(), mcpSyncClient -> { - ListResourcesResult resources = mcpSyncClient.listResources(null); - - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); - - // Test subscribe - assertThatCode(() -> mcpSyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) - .doesNotThrowAnyException(); - - // Test unsubscribe - assertThatCode(() -> mcpSyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) - .doesNotThrowAnyException(); - } - }); - } - - @Test - void testNotificationHandlers() { - AtomicBoolean toolsNotificationReceived = new AtomicBoolean(false); - AtomicBoolean resourcesNotificationReceived = new AtomicBoolean(false); - AtomicBoolean promptsNotificationReceived = new AtomicBoolean(false); - AtomicBoolean resourcesUpdatedNotificationReceived = new AtomicBoolean(false); - - withClient(createMcpTransport(), - builder -> builder.toolsChangeConsumer(tools -> toolsNotificationReceived.set(true)) - .resourcesChangeConsumer(resources -> resourcesNotificationReceived.set(true)) - .promptsChangeConsumer(prompts -> promptsNotificationReceived.set(true)) - .resourcesUpdateConsumer(resources -> resourcesUpdatedNotificationReceived.set(true)), - client -> { - - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); - }); - } - - // --------------------------------------- - // Logging Tests - // --------------------------------------- - - @Test - void testLoggingLevelsWithoutInitialization() { - verifyNotificationSucceedsWithImplicitInitialization( - client -> client.setLoggingLevel(McpSchema.LoggingLevel.DEBUG), "setting logging level"); - } - - @Test - void testLoggingLevels() { - withClient(createMcpTransport(), mcpSyncClient -> { - mcpSyncClient.initialize(); - // Test all logging levels - for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) { - assertThatCode(() -> mcpSyncClient.setLoggingLevel(level)).doesNotThrowAnyException(); - } - }); - } - - @Test - void testLoggingConsumer() { - AtomicBoolean logReceived = new AtomicBoolean(false); - withClient(createMcpTransport(), builder -> builder.requestTimeout(getRequestTimeout()) - .loggingConsumer(notification -> logReceived.set(true)), client -> { - assertThatCode(() -> { - client.initialize(); - client.close(); - }).doesNotThrowAnyException(); - }); - } - - @Test - void testLoggingWithNullNotification() { - withClient(createMcpTransport(), mcpSyncClient -> assertThatThrownBy(() -> mcpSyncClient.setLoggingLevel(null)) - .hasMessageContaining("Logging level must not be null")); - } - - @Test - void testSampling() { - McpClientTransport transport = createMcpTransport(); - - final String message = "Hello, world!"; - final String response = "Goodbye, world!"; - final int maxTokens = 100; - - AtomicReference receivedPrompt = new AtomicReference<>(); - AtomicReference receivedMessage = new AtomicReference<>(); - AtomicInteger receivedMaxTokens = new AtomicInteger(); - - withClient(transport, spec -> spec.capabilities(McpSchema.ClientCapabilities.builder().sampling().build()) - .sampling(request -> { - McpSchema.TextContent messageText = assertInstanceOf(McpSchema.TextContent.class, - request.messages().get(0).content()); - receivedPrompt.set(request.systemPrompt()); - receivedMessage.set(messageText.text()); - receivedMaxTokens.set(request.maxTokens()); - - return new McpSchema.CreateMessageResult(McpSchema.Role.USER, new McpSchema.TextContent(response), - "modelId", McpSchema.CreateMessageResult.StopReason.END_TURN); - }), client -> { - client.initialize(); - - McpSchema.CallToolResult result = client.callTool( - new McpSchema.CallToolRequest("sampleLLM", Map.of("prompt", message, "maxTokens", maxTokens))); - - // Verify tool response to ensure our sampling response was passed through - assertThat(result.content()).hasAtLeastOneElementOfType(McpSchema.TextContent.class); - assertThat(result.content()).allSatisfy(content -> { - if (!(content instanceof McpSchema.TextContent text)) - return; - - assertThat(text.text()).endsWith(response); // Prefixed - }); - - // Verify sampling request parameters received in our callback - assertThat(receivedPrompt.get()).isNotEmpty(); - assertThat(receivedMessage.get()).endsWith(message); // Prefixed - assertThat(receivedMaxTokens.get()).isEqualTo(maxTokens); - }); - } - - // --------------------------------------- - // Progress Notification Tests - // --------------------------------------- - - @Test - void testProgressConsumer() { - AtomicInteger progressNotificationCount = new AtomicInteger(0); - List receivedNotifications = new CopyOnWriteArrayList<>(); - CountDownLatch latch = new CountDownLatch(2); - - withClient(createMcpTransport(), builder -> builder.progressConsumer(notification -> { - System.out.println("Received progress notification: " + notification); - receivedNotifications.add(notification); - progressNotificationCount.incrementAndGet(); - latch.countDown(); - }), client -> { - client.initialize(); - - // Call a tool that sends progress notifications - CallToolRequest request = CallToolRequest.builder() - .name("longRunningOperation") - .arguments(Map.of("duration", 1, "steps", 2)) - .progressToken("test-token") - .build(); - - CallToolResult result = client.callTool(request); - - assertThat(result).isNotNull(); - - try { - // Wait for progress notifications to be processed - latch.await(3, TimeUnit.SECONDS); - } - catch (InterruptedException e) { - e.printStackTrace(); - } - - assertThat(progressNotificationCount.get()).isEqualTo(2); - - assertThat(receivedNotifications).isNotEmpty(); - assertThat(receivedNotifications.get(0).progressToken()).isEqualTo("test-token"); - }); - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java deleted file mode 100644 index 090710248..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java +++ /dev/null @@ -1,722 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import java.time.Duration; -import java.util.List; - -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; -import io.modelcontextprotocol.spec.McpSchema.Prompt; -import io.modelcontextprotocol.spec.McpSchema.PromptMessage; -import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; -import io.modelcontextprotocol.spec.McpSchema.Resource; -import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; -import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.spec.McpServerTransportProvider; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Test suite for the {@link McpAsyncServer} that can be used with different - * {@link io.modelcontextprotocol.spec.McpServerTransportProvider} implementations. - * - * @author Christian Tzolov - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpAsyncServerTests { - - private static final String TEST_TOOL_NAME = "test-tool"; - - private static final String TEST_RESOURCE_URI = "test://resource"; - - private static final String TEST_PROMPT_NAME = "test-prompt"; - - abstract protected McpServer.AsyncSpecification prepareAsyncServerBuilder(); - - protected void onStart() { - } - - protected void onClose() { - } - - @BeforeEach - void setUp() { - } - - @AfterEach - void tearDown() { - onClose(); - } - - // --------------------------------------- - // Server Lifecycle Tests - // --------------------------------------- - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpServer.async((McpServerTransportProvider) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Transport provider must not be null"); - - assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo((McpSchema.Implementation) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Server info must not be null"); - } - - @Test - void testGracefulShutdown() { - McpServer.AsyncSpecification builder = prepareAsyncServerBuilder(); - var mcpAsyncServer = builder.serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete(); - } - - @Test - void testImmediateClose() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpAsyncServer::close).doesNotThrowAnyException(); - } - - // --------------------------------------- - // 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() - .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(McpServerFeatures.AsyncToolSpecification.builder() - .tool(newTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build())).verifyComplete(); - - 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() - .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()) - .toolCall(duplicateTool, - (exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(); - - StepVerifier.create(mcpAsyncServer.addTool(McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build())).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testDuplicateToolCallDuringBuilding() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("duplicate-build-toolcall") - .title("Duplicate toolcall during building") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(duplicateTool, - (exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .toolCall(duplicateTool, - (exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) // Duplicate! - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'duplicate-build-toolcall' is already registered."); - } - - @Test - void testDuplicateToolsInBatchListRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-list-tool") - .title("Duplicate tool in batch list") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - List specs = List.of( - McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(), - McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build() // Duplicate! - ); - - assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(specs) - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'batch-list-tool' is already registered."); - } - - @Test - void testDuplicateToolsInBatchVarargsRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-varargs-tool") - .title("Duplicate tool in batch varargs") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(), - McpServerFeatures.AsyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build() // Duplicate! - ) - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'batch-varargs-tool' is already registered."); - } - - @Test - void testRemoveTool() { - Tool too = 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()) - .toolCall(too, - (exchange, request) -> Mono - .just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(); - - StepVerifier.create(mcpAsyncServer.removeTool(TEST_TOOL_NAME)).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentTool() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - StepVerifier.create(mcpAsyncServer.removeTool("nonexistent-tool")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testNotifyToolsListChanged() { - Tool too = 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()) - .toolCall(too, - (exchange, args) -> Mono.just(CallToolResult.builder().content(List.of()).isError(false).build())) - .build(); - - StepVerifier.create(mcpAsyncServer.notifyToolsListChanged()).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Resources Tests - // --------------------------------------- - - @Test - void testNotifyResourcesListChanged() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testNotifyResourcesUpdated() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier - .create(mcpAsyncServer - .notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(TEST_RESOURCE_URI))) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddResource() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .title("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification( - resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier.create(mcpAsyncServer.addResource(specification)).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddResourceWithNullSpecification() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - StepVerifier.create(mcpAsyncServer.addResource((McpServerFeatures.AsyncResourceSpecification) null)) - .verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalArgumentException.class).hasMessage("Resource must not be null"); - }); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddResourceWithoutCapability() { - // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .title("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification( - resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier.create(serverWithoutResources.addResource(specification)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - }); - } - - @Test - void testRemoveResourceWithoutCapability() { - // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - }); - } - - @Test - void testListResources() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .title("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification( - resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier - .create(mcpAsyncServer.addResource(specification).then(mcpAsyncServer.listResources().collectList())) - .expectNextMatches(resources -> resources.size() == 1 && resources.get(0).uri().equals(TEST_RESOURCE_URI)) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveResource() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .title("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.AsyncResourceSpecification specification = new McpServerFeatures.AsyncResourceSpecification( - resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier - .create(mcpAsyncServer.addResource(specification).then(mcpAsyncServer.removeResource(TEST_RESOURCE_URI))) - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentResource() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - // Removing a non-existent resource should complete successfully (no error) - // as per the new implementation that just logs a warning - StepVerifier.create(mcpAsyncServer.removeResource("nonexistent://resource")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Resource Template Tests - // --------------------------------------- - - @Test - void testAddResourceTemplate() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier.create(mcpAsyncServer.addResourceTemplate(specification)).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddResourceTemplateWithoutCapability() { - // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - StepVerifier.create(serverWithoutResources.addResourceTemplate(specification)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - }); - } - - @Test - void testRemoveResourceTemplate() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .resourceTemplates(specification) - .build(); - - StepVerifier.create(mcpAsyncServer.removeResourceTemplate("test://template/{id}")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveResourceTemplateWithoutCapability() { - // Create a server without resource capabilities - McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(serverWithoutResources.removeResourceTemplate("test://template/{id}")) - .verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - }); - } - - @Test - void testRemoveNonexistentResourceTemplate() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - StepVerifier.create(mcpAsyncServer.removeResourceTemplate("nonexistent://template/{id}")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testListResourceTemplates() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( - template, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .resourceTemplates(specification) - .build(); - - // Note: Based on the current implementation, listResourceTemplates() returns - // Flux - // This appears to be a bug in the implementation that should return - // Flux - StepVerifier.create(mcpAsyncServer.listResourceTemplates().collectList()) - .expectNextMatches(resources -> resources.size() >= 0) // Just verify it - // doesn't error - .verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Prompts Tests - // --------------------------------------- - - @Test - void testNotifyPromptsListChanged() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testAddPromptWithNullSpecification() { - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(false).build()) - .build(); - - StepVerifier.create(mcpAsyncServer.addPrompt((McpServerFeatures.AsyncPromptSpecification) null)) - .verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Prompt specification must not be null"); - }); - } - - @Test - void testAddPromptWithoutCapability() { - // Create a server without prompt capabilities - McpAsyncServer serverWithoutPrompts = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", "Test Prompt", List.of()); - McpServerFeatures.AsyncPromptSpecification specification = new McpServerFeatures.AsyncPromptSpecification( - prompt, (exchange, req) -> Mono.just(new GetPromptResult("Test prompt description", List - .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))))); - - StepVerifier.create(serverWithoutPrompts.addPrompt(specification)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessage("Server must be configured with prompt capabilities"); - }); - } - - @Test - void testRemovePromptWithoutCapability() { - // Create a server without prompt capabilities - McpAsyncServer serverWithoutPrompts = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> { - assertThat(error).isInstanceOf(IllegalStateException.class) - .hasMessage("Server must be configured with prompt capabilities"); - }); - } - - @Test - void testRemovePrompt() { - String TEST_PROMPT_NAME_TO_REMOVE = "TEST_PROMPT_NAME678"; - - Prompt prompt = new Prompt(TEST_PROMPT_NAME_TO_REMOVE, "Test Prompt", "Test Prompt", List.of()); - McpServerFeatures.AsyncPromptSpecification specification = new McpServerFeatures.AsyncPromptSpecification( - prompt, (exchange, req) -> Mono.just(new GetPromptResult("Test prompt description", List - .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))))); - - var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(true).build()) - .prompts(specification) - .build(); - - StepVerifier.create(mcpAsyncServer.removePrompt(TEST_PROMPT_NAME_TO_REMOVE)).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentPrompt() { - var mcpAsyncServer2 = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(true).build()) - .build(); - - StepVerifier.create(mcpAsyncServer2.removePrompt("nonexistent-prompt")).verifyComplete(); - - assertThatCode(() -> mcpAsyncServer2.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - } - - // --------------------------------------- - // Roots Tests - // --------------------------------------- - - @Test - void testRootsChangeHandlers() { - // Test with single consumer - var rootsReceived = new McpSchema.Root[1]; - var consumerCalled = new boolean[1]; - - var singleConsumerServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> Mono.fromRunnable(() -> { - consumerCalled[0] = true; - if (!roots.isEmpty()) { - rootsReceived[0] = roots.get(0); - } - }))) - .build(); - - assertThat(singleConsumerServer).isNotNull(); - assertThatCode(() -> singleConsumerServer.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - onClose(); - - // Test with multiple consumers - var consumer1Called = new boolean[1]; - var consumer2Called = new boolean[1]; - var rootsContent = new List[1]; - - var multipleConsumersServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> Mono.fromRunnable(() -> { - consumer1Called[0] = true; - rootsContent[0] = roots; - }), (exchange, roots) -> Mono.fromRunnable(() -> consumer2Called[0] = true))) - .build(); - - assertThat(multipleConsumersServer).isNotNull(); - assertThatCode(() -> multipleConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - onClose(); - - // Test error handling - var errorHandlingServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> { - throw new RuntimeException("Test error"); - })) - .build(); - - assertThat(errorHandlingServer).isNotNull(); - assertThatCode(() -> errorHandlingServer.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - onClose(); - - // Test without consumers - var noConsumersServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThat(noConsumersServer).isNotNull(); - assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10))) - .doesNotThrowAnyException(); - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java deleted file mode 100644 index 1f5387f37..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpClientServerIntegrationTests.java +++ /dev/null @@ -1,1756 +0,0 @@ -/* - * Copyright 2024 - 2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.stream.Collectors; - -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.spec.McpError; -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; -import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; -import io.modelcontextprotocol.spec.McpSchema.CompleteResult; -import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; -import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; -import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; -import io.modelcontextprotocol.spec.McpSchema.ElicitResult; -import io.modelcontextprotocol.spec.McpSchema.InitializeResult; -import io.modelcontextprotocol.spec.McpSchema.ModelPreferences; -import io.modelcontextprotocol.spec.McpSchema.Prompt; -import io.modelcontextprotocol.spec.McpSchema.PromptArgument; -import io.modelcontextprotocol.spec.McpSchema.PromptReference; -import io.modelcontextprotocol.spec.McpSchema.Role; -import io.modelcontextprotocol.spec.McpSchema.Root; -import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; -import io.modelcontextprotocol.spec.McpSchema.TextContent; -import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.util.Utils; -import net.javacrumbs.jsonunit.core.Option; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertWith; -import static org.awaitility.Awaitility.await; -import static org.mockito.Mockito.mock; - -public abstract class AbstractMcpClientServerIntegrationTests { - - protected ConcurrentHashMap clientBuilders = new ConcurrentHashMap<>(); - - abstract protected void prepareClients(int port, String mcpEndpoint); - - abstract protected McpServer.AsyncSpecification prepareAsyncServerBuilder(); - - abstract protected McpServer.SyncSpecification prepareSyncServerBuilder(); - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void simple(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(1000)) - .build(); - try ( - // Create client without sampling capabilities - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) - .requestTimeout(Duration.ofSeconds(1000)) - .build()) { - - assertThat(client.initialize()).isNotNull(); - - } - finally { - server.closeGracefully().block(); - } - } - - // --------------------------------------- - // Sampling Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageWithoutSamplingCapabilities(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - return exchange.createMessage(mock(McpSchema.CreateMessageRequest.class)) - .then(Mono.just(mock(CallToolResult.class))); - }) - .build(); - - var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - - try ( - // Create client without sampling capabilities - var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) - .build()) { - - assertThat(client.initialize()).isNotNull(); - - try { - client.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - } - catch (McpError e) { - assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be configured with sampling capabilities"); - } - } - finally { - server.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - Function samplingHandler = request -> { - assertThat(request.messages()).hasSize(1); - assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); - - return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", - CreateMessageResult.StopReason.STOP_SEQUENCE); - }; - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - AtomicReference samplingResult = new AtomicReference<>(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) - .modelPreferences(ModelPreferences.builder() - .hints(List.of()) - .costPriority(1.0) - .speedPriority(1.0) - .intelligencePriority(1.0) - .build()) - .build(); - - return exchange.createMessage(createMessageRequest) - .doOnNext(samplingResult::set) - .thenReturn(callResponse); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().sampling().build()) - .sampling(samplingHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response).isEqualTo(callResponse); - - assertWith(samplingResult.get(), result -> { - assertThat(result).isNotNull(); - assertThat(result.role()).isEqualTo(Role.USER); - assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message"); - assertThat(result.model()).isEqualTo("MockModelName"); - assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE); - }); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws InterruptedException { - - // Client - - var clientBuilder = clientBuilders.get(clientType); - - Function samplingHandler = request -> { - assertThat(request.messages()).hasSize(1); - assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); - try { - TimeUnit.SECONDS.sleep(2); - } - catch (InterruptedException e) { - throw new RuntimeException(e); - } - return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", - CreateMessageResult.StopReason.STOP_SEQUENCE); - }; - - // Server - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - AtomicReference samplingResult = new AtomicReference<>(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) - .modelPreferences(ModelPreferences.builder() - .hints(List.of()) - .costPriority(1.0) - .speedPriority(1.0) - .intelligencePriority(1.0) - .build()) - .build(); - - return exchange.createMessage(createMessageRequest) - .doOnNext(samplingResult::set) - .thenReturn(callResponse); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(4)) - .tools(tool) - .build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().sampling().build()) - .sampling(samplingHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response).isEqualTo(callResponse); - - assertWith(samplingResult.get(), result -> { - assertThat(result).isNotNull(); - assertThat(result.role()).isEqualTo(Role.USER); - assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message"); - assertThat(result.model()).isEqualTo("MockModelName"); - assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE); - }); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageWithRequestTimeoutFail(String clientType) throws InterruptedException { - - var clientBuilder = clientBuilders.get(clientType); - - Function samplingHandler = request -> { - assertThat(request.messages()).hasSize(1); - assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); - try { - TimeUnit.SECONDS.sleep(2); - } - catch (InterruptedException e) { - throw new RuntimeException(e); - } - return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName", - CreateMessageResult.StopReason.STOP_SEQUENCE); - }; - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) - .modelPreferences(ModelPreferences.builder() - .hints(List.of()) - .costPriority(1.0) - .speedPriority(1.0) - .intelligencePriority(1.0) - .build()) - .build(); - - return exchange.createMessage(createMessageRequest).thenReturn(callResponse); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(1)) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().sampling().build()) - .sampling(samplingHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThatExceptionOfType(McpError.class).isThrownBy(() -> { - mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - }).withMessageContaining("1000ms"); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Elicitation Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithoutElicitationCapabilities(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> exchange.createElicitation(mock(ElicitRequest.class)) - .then(Mono.just(mock(CallToolResult.class)))) - .build(); - - var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - - // Create client without elicitation capabilities - try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build()) { - - assertThat(client.initialize()).isNotNull(); - - try { - client.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - } - catch (McpError e) { - assertThat(e).isInstanceOf(McpError.class) - .hasMessage("Client must be configured with elicitation capabilities"); - } - } - finally { - server.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - Function elicitationHandler = request -> { - assertThat(request.message()).isNotEmpty(); - assertThat(request.requestedSchema()).isNotNull(); - - return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, - Map.of("message", request.message())); - }; - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema( - Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) - .build(); - - StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); - assertThat(result.content().get("message")).isEqualTo("Test message"); - }).verifyComplete(); - - return Mono.just(callResponse); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().elicitation().build()) - .elicitation(elicitationHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response).isEqualTo(callResponse); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - Function elicitationHandler = request -> { - assertThat(request.message()).isNotEmpty(); - assertThat(request.requestedSchema()).isNotNull(); - return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message())); - }; - - CallToolResult callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - AtomicReference resultRef = new AtomicReference<>(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema( - Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) - .build(); - - return exchange.createElicitation(elicitationRequest) - .doOnNext(resultRef::set) - .then(Mono.just(callResponse)); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(3)) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().elicitation().build()) - .elicitation(elicitationHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response).isEqualTo(callResponse); - assertWith(resultRef.get(), result -> { - assertThat(result).isNotNull(); - assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); - assertThat(result.content().get("message")).isEqualTo("Test message"); - }); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithRequestTimeoutFail(String clientType) { - - var latch = new CountDownLatch(1); - - var clientBuilder = clientBuilders.get(clientType); - - Function elicitationHandler = request -> { - assertThat(request.message()).isNotEmpty(); - assertThat(request.requestedSchema()).isNotNull(); - - try { - if (!latch.await(2, TimeUnit.SECONDS)) { - throw new RuntimeException("Timeout waiting for elicitation processing"); - } - } - catch (InterruptedException e) { - throw new RuntimeException(e); - } - return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message())); - }; - - CallToolResult callResponse = CallToolResult.builder().addContent(new TextContent("CALL RESPONSE")).build(); - - AtomicReference resultRef = new AtomicReference<>(); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - var elicitationRequest = ElicitRequest.builder() - .message("Test message") - .requestedSchema( - Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) - .build(); - - return exchange.createElicitation(elicitationRequest) - .doOnNext(resultRef::set) - .then(Mono.just(callResponse)); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .requestTimeout(Duration.ofSeconds(1)) // 1 second. - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) - .capabilities(ClientCapabilities.builder().elicitation().build()) - .elicitation(elicitationHandler) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThatExceptionOfType(McpError.class).isThrownBy(() -> { - mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - }).withMessageContaining("within 1000ms"); - - ElicitResult elicitResult = resultRef.get(); - assertThat(elicitResult).isNull(); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Roots Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsSuccess(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - - List roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2")); - - AtomicReference> rootsRef = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder() - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) - .build(); - - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(roots) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThat(rootsRef.get()).isNull(); - - mcpClient.rootsListChangedNotification(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).containsAll(roots); - }); - - // Remove a root - mcpClient.removeRoot(roots.get(0).uri()); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).containsAll(List.of(roots.get(1))); - }); - - // Add a new root - var root3 = new Root("uri3://", "root3"); - mcpClient.addRoot(root3); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3)); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsWithoutCapability(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - exchange.listRoots(); // try to list roots - - return mock(CallToolResult.class); - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().rootsChangeHandler((exchange, rootsUpdate) -> { - }).tools(tool).build(); - - try ( - // Create client without roots capability - // No roots capability - var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build()) { - - assertThat(mcpClient.initialize()).isNotNull(); - - // Attempt to list roots should fail - try { - mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - } - catch (McpError e) { - assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported"); - } - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsNotificationWithEmptyRootsList(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - AtomicReference> rootsRef = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder() - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) - .build(); - - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(List.of()) // Empty roots list - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - mcpClient.rootsListChangedNotification(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).isEmpty(); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsWithMultipleHandlers(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - List roots = List.of(new Root("uri1://", "root1")); - - AtomicReference> rootsRef1 = new AtomicReference<>(); - AtomicReference> rootsRef2 = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder() - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef1.set(rootsUpdate)) - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate)) - .build(); - - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(roots) - .build()) { - - assertThat(mcpClient.initialize()).isNotNull(); - - mcpClient.rootsListChangedNotification(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef1.get()).containsAll(roots); - assertThat(rootsRef2.get()).containsAll(roots); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsServerCloseWithActiveSubscription(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - List roots = List.of(new Root("uri1://", "root1")); - - AtomicReference> rootsRef = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder() - .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) - .build(); - - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) - .roots(roots) - .build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - mcpClient.rootsListChangedNotification(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(rootsRef.get()).containsAll(roots); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - // --------------------------------------- - // Tools Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolCallSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var responseBodyIsNullOrBlank = new AtomicBoolean(false); - var callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=importantValue")) - .build(); - McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - try { - HttpResponse response = HttpClient.newHttpClient() - .send(HttpRequest.newBuilder() - .uri(URI.create( - "https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")) - .GET() - .build(), HttpResponse.BodyHandlers.ofString()); - String responseBody = response.body(); - responseBodyIsNullOrBlank.set(!Utils.hasText(responseBody)); - } - catch (Exception e) { - e.printStackTrace(); - } - - return callResponse; - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool1) - .build(); - - try (var mcpClient = clientBuilder.build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(responseBodyIsNullOrBlank.get()).isFalse(); - assertThat(response).isNotNull().isEqualTo(callResponse); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - McpSyncServer mcpServer = prepareSyncServerBuilder() - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("tool1") - .description("tool1 description") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> { - // We trigger a timeout on blocking read, raising an exception - Mono.never().block(Duration.ofSeconds(1)); - return null; - }) - .build()) - .build(); - - try (var mcpClient = clientBuilder.requestTimeout(Duration.ofMillis(6666)).build()) { - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // We expect the tool call to fail immediately with the exception raised by - // the offending tool instead of getting back a timeout. - assertThatExceptionOfType(McpError.class) - .isThrownBy(() -> mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()))) - .withMessageContaining("Timeout on blocking read"); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolCallSuccessWithTransportContextExtraction(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var transportContextIsNull = new AtomicBoolean(false); - var transportContextIsEmpty = new AtomicBoolean(false); - var responseBodyIsNullOrBlank = new AtomicBoolean(false); - - var expectedCallResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=value")) - .build(); - McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - - McpTransportContext transportContext = exchange.transportContext(); - transportContextIsNull.set(transportContext == null); - transportContextIsEmpty.set(transportContext.equals(McpTransportContext.EMPTY)); - String ctxValue = (String) transportContext.get("important"); - - try { - String responseBody = "TOOL RESPONSE"; - responseBodyIsNullOrBlank.set(!Utils.hasText(responseBody)); - } - catch (Exception e) { - e.printStackTrace(); - } - - return McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=" + ctxValue)) - .build(); - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool1) - .build(); - - try (var mcpClient = clientBuilder.build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); - - assertThat(transportContextIsNull.get()).isFalse(); - assertThat(transportContextIsEmpty.get()).isFalse(); - assertThat(responseBodyIsNullOrBlank.get()).isFalse(); - assertThat(response).isNotNull().isEqualTo(expectedCallResponse); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolListChangeHandlingSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var callResponse = McpSchema.CallToolResult.builder() - .addContent(new McpSchema.TextContent("CALL RESPONSE")) - .build(); - - McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) - .callHandler((exchange, request) -> { - // perform a blocking call to a remote service - try { - HttpResponse response = HttpClient.newHttpClient() - .send(HttpRequest.newBuilder() - .uri(URI.create( - "https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")) - .GET() - .build(), HttpResponse.BodyHandlers.ofString()); - String responseBody = response.body(); - assertThat(responseBody).isNotBlank(); - } - catch (Exception e) { - e.printStackTrace(); - } - return callResponse; - }) - .build(); - - AtomicReference> toolsRef = new AtomicReference<>(); - - var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool1) - .build(); - - try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { - // perform a blocking call to a remote service - try { - HttpResponse response = HttpClient.newHttpClient() - .send(HttpRequest.newBuilder() - .uri(URI.create( - "https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")) - .GET() - .build(), HttpResponse.BodyHandlers.ofString()); - String responseBody = response.body(); - assertThat(responseBody).isNotBlank(); - toolsRef.set(toolsUpdate); - } - catch (Exception e) { - e.printStackTrace(); - } - }).build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - assertThat(toolsRef.get()).isNull(); - - assertThat(mcpClient.listTools().tools()).contains(tool1.tool()); - - mcpServer.notifyToolsListChanged(); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(toolsRef.get()).containsAll(List.of(tool1.tool())); - }); - - // Remove a tool - mcpServer.removeTool("tool1"); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(toolsRef.get()).isEmpty(); - }); - - // Add a new tool - McpServerFeatures.SyncToolSpecification tool2 = McpServerFeatures.SyncToolSpecification.builder() - .tool(Tool.builder() - .name("tool2") - .description("tool2 description") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> callResponse) - .build(); - - mcpServer.addTool(tool2); - - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(toolsRef.get()).containsAll(List.of(tool2.tool())); - }); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testInitialize(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - var mcpServer = prepareSyncServerBuilder().build(); - - try (var mcpClient = clientBuilder.build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - } - finally { - mcpServer.closeGracefully(); - } - } - - // --------------------------------------- - // Logging Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testLoggingNotification(String clientType) throws InterruptedException { - int expectedNotificationsCount = 3; - CountDownLatch latch = new CountDownLatch(expectedNotificationsCount); - // Create a list to store received logging notifications - List receivedNotifications = new CopyOnWriteArrayList<>(); - - var clientBuilder = clientBuilders.get(clientType); - - // Create server with a tool that sends logging notifications - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder() - .name("logging-test") - .description("Test logging notifications") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> { - - // Create and send notifications with different levels - - //@formatter:off - return exchange // This should be filtered out (DEBUG < NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.DEBUG) - .logger("test-logger") - .data("Debug message") - .build()) - .then(exchange // This should be sent (NOTICE >= NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.NOTICE) - .logger("test-logger") - .data("Notice message") - .build())) - .then(exchange // This should be sent (ERROR > NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) - .logger("test-logger") - .data("Error message") - .build())) - .then(exchange // This should be filtered out (INFO < NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.INFO) - .logger("test-logger") - .data("Another info message") - .build())) - .then(exchange // This should be sent (ERROR >= NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) - .logger("test-logger") - .data("Another error message") - .build())) - .thenReturn(CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Logging test completed"))) - .isError(false) - .build()); - //@formatter:on - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try ( - // Create client with logging notification handler - var mcpClient = clientBuilder.loggingConsumer(notification -> { - receivedNotifications.add(notification); - latch.countDown(); - }).build()) { - - // Initialize client - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Set minimum logging level to NOTICE - mcpClient.setLoggingLevel(McpSchema.LoggingLevel.NOTICE); - - // Call the tool that sends logging notifications - CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("logging-test", Map.of())); - assertThat(result).isNotNull(); - assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Logging test completed"); - - assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue(); - - // Should have received 3 notifications (1 NOTICE and 2 ERROR) - assertThat(receivedNotifications).hasSize(expectedNotificationsCount); - - Map notificationMap = receivedNotifications.stream() - .collect(Collectors.toMap(n -> n.data(), n -> n)); - - // First notification should be NOTICE level - assertThat(notificationMap.get("Notice message").level()).isEqualTo(McpSchema.LoggingLevel.NOTICE); - assertThat(notificationMap.get("Notice message").logger()).isEqualTo("test-logger"); - assertThat(notificationMap.get("Notice message").data()).isEqualTo("Notice message"); - - // Second notification should be ERROR level - assertThat(notificationMap.get("Error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR); - assertThat(notificationMap.get("Error message").logger()).isEqualTo("test-logger"); - assertThat(notificationMap.get("Error message").data()).isEqualTo("Error message"); - - // Third notification should be ERROR level - assertThat(notificationMap.get("Another error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR); - assertThat(notificationMap.get("Another error message").logger()).isEqualTo("test-logger"); - assertThat(notificationMap.get("Another error message").data()).isEqualTo("Another error message"); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Progress Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testProgressNotification(String clientType) throws InterruptedException { - int expectedNotificationsCount = 4; // 3 notifications + 1 for another progress - // token - CountDownLatch latch = new CountDownLatch(expectedNotificationsCount); - // Create a list to store received logging notifications - List receivedNotifications = new CopyOnWriteArrayList<>(); - - var clientBuilder = clientBuilders.get(clientType); - - // Create server with a tool that sends logging notifications - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(McpSchema.Tool.builder() - .name("progress-test") - .description("Test progress notifications") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> { - - // Create and send notifications - var progressToken = (String) request.meta().get("progressToken"); - - return exchange - .progressNotification( - new McpSchema.ProgressNotification(progressToken, 0.0, 1.0, "Processing started")) - .then(exchange.progressNotification( - new McpSchema.ProgressNotification(progressToken, 0.5, 1.0, "Processing data"))) - .then(// Send a progress notification with another progress value - // should - exchange.progressNotification(new McpSchema.ProgressNotification("another-progress-token", - 0.0, 1.0, "Another processing started"))) - .then(exchange.progressNotification( - new McpSchema.ProgressNotification(progressToken, 1.0, 1.0, "Processing completed"))) - .thenReturn(CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Progress test completed"))) - .isError(false) - .build()); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try ( - // Create client with progress notification handler - var mcpClient = clientBuilder.progressConsumer(notification -> { - receivedNotifications.add(notification); - latch.countDown(); - }).build()) { - - // Initialize client - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Call the tool that sends progress notifications - McpSchema.CallToolRequest callToolRequest = McpSchema.CallToolRequest.builder() - .name("progress-test") - .meta(Map.of("progressToken", "test-progress-token")) - .build(); - CallToolResult result = mcpClient.callTool(callToolRequest); - assertThat(result).isNotNull(); - assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Progress test completed"); - - assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue(); - - // Should have received 3 notifications - assertThat(receivedNotifications).hasSize(expectedNotificationsCount); - - Map notificationMap = receivedNotifications.stream() - .collect(Collectors.toMap(n -> n.message(), n -> n)); - - // First notification should be 0.0/1.0 progress - assertThat(notificationMap.get("Processing started").progressToken()).isEqualTo("test-progress-token"); - assertThat(notificationMap.get("Processing started").progress()).isEqualTo(0.0); - assertThat(notificationMap.get("Processing started").total()).isEqualTo(1.0); - assertThat(notificationMap.get("Processing started").message()).isEqualTo("Processing started"); - - // Second notification should be 0.5/1.0 progress - assertThat(notificationMap.get("Processing data").progressToken()).isEqualTo("test-progress-token"); - assertThat(notificationMap.get("Processing data").progress()).isEqualTo(0.5); - assertThat(notificationMap.get("Processing data").total()).isEqualTo(1.0); - assertThat(notificationMap.get("Processing data").message()).isEqualTo("Processing data"); - - // Third notification should be another progress token with 0.0/1.0 progress - assertThat(notificationMap.get("Another processing started").progressToken()) - .isEqualTo("another-progress-token"); - assertThat(notificationMap.get("Another processing started").progress()).isEqualTo(0.0); - assertThat(notificationMap.get("Another processing started").total()).isEqualTo(1.0); - assertThat(notificationMap.get("Another processing started").message()) - .isEqualTo("Another processing started"); - - // Fourth notification should be 1.0/1.0 progress - assertThat(notificationMap.get("Processing completed").progressToken()).isEqualTo("test-progress-token"); - assertThat(notificationMap.get("Processing completed").progress()).isEqualTo(1.0); - assertThat(notificationMap.get("Processing completed").total()).isEqualTo(1.0); - assertThat(notificationMap.get("Processing completed").message()).isEqualTo("Processing completed"); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Completion Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : Completion call") - @MethodSource("clientsForTesting") - void testCompletionShouldReturnExpectedSuggestions(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - - var expectedValues = List.of("python", "pytorch", "pyside"); - var completionResponse = new McpSchema.CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total - true // hasMore - )); - - AtomicReference samplingRequest = new AtomicReference<>(); - BiFunction completionHandler = (mcpSyncServerExchange, - request) -> { - samplingRequest.set(request); - return completionResponse; - }; - - var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().completions().build()) - .prompts(new McpServerFeatures.SyncPromptSpecification( - new Prompt("code_review", "Code review", "this is code review prompt", - List.of(new PromptArgument("language", "Language", "string", false))), - (mcpSyncServerExchange, getPromptRequest) -> null)) - .completions(new McpServerFeatures.SyncCompletionSpecification( - new McpSchema.PromptReference(PromptReference.TYPE, "code_review", "Code review"), - completionHandler)) - .build(); - - try (var mcpClient = clientBuilder.build()) { - - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - CompleteRequest request = new CompleteRequest( - new PromptReference(PromptReference.TYPE, "code_review", "Code review"), - new CompleteRequest.CompleteArgument("language", "py")); - - CompleteResult result = mcpClient.completeCompletion(request); - - assertThat(result).isNotNull(); - - assertThat(samplingRequest.get().argument().name()).isEqualTo("language"); - assertThat(samplingRequest.get().argument().value()).isEqualTo("py"); - assertThat(samplingRequest.get().ref().type()).isEqualTo(PromptReference.TYPE); - } - finally { - mcpServer.closeGracefully(); - } - } - - // --------------------------------------- - // Ping Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testPingSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - // Create server with a tool that uses ping functionality - AtomicReference executionOrder = new AtomicReference<>(""); - - McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() - .tool(Tool.builder() - .name("ping-async-test") - .description("Test ping async behavior") - .inputSchema(EMPTY_JSON_SCHEMA) - .build()) - .callHandler((exchange, request) -> { - - executionOrder.set(executionOrder.get() + "1"); - - // Test async ping behavior - return exchange.ping().doOnNext(result -> { - - assertThat(result).isNotNull(); - // Ping should return an empty object or map - assertThat(result).isInstanceOf(Map.class); - - executionOrder.set(executionOrder.get() + "2"); - assertThat(result).isNotNull(); - }).then(Mono.fromCallable(() -> { - executionOrder.set(executionOrder.get() + "3"); - return CallToolResult.builder() - .content(List.of(new McpSchema.TextContent("Async ping test completed"))) - .isError(false) - .build(); - })); - }) - .build(); - - var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.build()) { - - // Initialize client - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Call the tool that tests ping async behavior - CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("ping-async-test", Map.of())); - assertThat(result).isNotNull(); - assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Async ping test completed"); - - // Verify execution order - assertThat(executionOrder.get()).isEqualTo("123"); - } - finally { - mcpServer.closeGracefully().block(); - } - } - - // --------------------------------------- - // Tool Structured Output Schema Tests - // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testStructuredOutputValidationSuccess(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - - // Create a tool with output schema - Map outputSchema = Map.of( - "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation", - Map.of("type", "string"), "timestamp", Map.of("type", "string")), - "required", List.of("result", "operation")); - - Tool calculatorTool = Tool.builder() - .name("calculator") - .description("Performs mathematical calculations") - .outputSchema(outputSchema) - .build(); - - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(calculatorTool) - .callHandler((exchange, request) -> { - String expression = (String) request.arguments().getOrDefault("expression", "2 + 3"); - double result = evaluateExpression(expression); - return CallToolResult.builder() - .structuredContent( - Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z")) - .build(); - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.build()) { - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Verify tool is listed with output schema - var toolsList = mcpClient.listTools(); - assertThat(toolsList.tools()).hasSize(1); - assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator"); - // Note: outputSchema might be null in sync server, but validation still works - - // Call tool with valid structured output - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); - - assertThat(response).isNotNull(); - assertThat(response.isError()).isFalse(); - - // In WebMVC, structured content is returned properly - if (response.structuredContent() != null) { - assertThat((Map) response.structuredContent()).containsEntry("result", 5.0) - .containsEntry("operation", "2 + 3") - .containsEntry("timestamp", "2024-01-01T10:00:00Z"); - } - else { - // Fallback to checking content if structured content is not available - assertThat(response.content()).isNotEmpty(); - } - - assertThat(response.structuredContent()).isNotNull(); - assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) - .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) - .isObject() - .isEqualTo(json(""" - {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "httpclient" }) - void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - - // Create a tool with output schema that returns an array of objects - Map outputSchema = Map - .of( // @formatter:off - "type", "array", - "items", Map.of( - "type", "object", - "properties", Map.of( - "name", Map.of("type", "string"), - "age", Map.of("type", "number")), - "required", List.of("name", "age"))); // @formatter:on - - Tool calculatorTool = Tool.builder() - .name("getMembers") - .description("Returns a list of members") - .outputSchema(outputSchema) - .build(); - - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(calculatorTool) - .callHandler((exchange, request) -> { - return CallToolResult.builder() - .structuredContent(List.of(Map.of("name", "John", "age", 30), Map.of("name", "Peter", "age", 25))) - .build(); - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.build()) { - assertThat(mcpClient.initialize()).isNotNull(); - - // Call tool with valid structured output of type array - CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("getMembers", Map.of())); - - assertThat(response).isNotNull(); - assertThat(response.isError()).isFalse(); - - assertThat(response.structuredContent()).isNotNull(); - assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) - .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) - .isArray() - .hasSize(2) - .containsExactlyInAnyOrder(json(""" - {"name":"John","age":30}"""), json(""" - {"name":"Peter","age":25}""")); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "httpclient" }) - void testStructuredOutputWithInHandlerError(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - - // Create a tool with output schema - Map outputSchema = Map.of( - "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation", - Map.of("type", "string"), "timestamp", Map.of("type", "string")), - "required", List.of("result", "operation")); - - Tool calculatorTool = Tool.builder() - .name("calculator") - .description("Performs mathematical calculations") - .outputSchema(outputSchema) - .build(); - - // Handler that returns an error result - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(calculatorTool) - .callHandler((exchange, request) -> CallToolResult.builder() - .isError(true) - .content(List.of(new TextContent("Error calling tool: Simulated in-handler error"))) - .build()) - .build(); - - var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.build()) { - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Verify tool is listed with output schema - var toolsList = mcpClient.listTools(); - assertThat(toolsList.tools()).hasSize(1); - assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator"); - // Note: outputSchema might be null in sync server, but validation still works - - // Call tool with valid structured output - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); - - assertThat(response).isNotNull(); - assertThat(response.isError()).isTrue(); - assertThat(response.content()).isNotEmpty(); - assertThat(response.content()) - .containsExactly(new McpSchema.TextContent("Error calling tool: Simulated in-handler error")); - assertThat(response.structuredContent()).isNull(); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "httpclient" }) - void testStructuredOutputValidationFailure(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - // Create a tool with output schema - Map outputSchema = Map.of("type", "object", "properties", - Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required", - List.of("result", "operation")); - - Tool calculatorTool = Tool.builder() - .name("calculator") - .description("Performs mathematical calculations") - .outputSchema(outputSchema) - .build(); - - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(calculatorTool) - .callHandler((exchange, request) -> { - // Return invalid structured output. Result should be number, missing - // operation - return CallToolResult.builder() - .addTextContent("Invalid calculation") - .structuredContent(Map.of("result", "not-a-number", "extra", "field")) - .build(); - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.build()) { - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Call tool with invalid structured output - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); - - assertThat(response).isNotNull(); - assertThat(response.isError()).isTrue(); - assertThat(response.content()).hasSize(1); - assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - - String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); - assertThat(errorMessage).contains("Validation failed"); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testStructuredOutputMissingStructuredContent(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - // Create a tool with output schema - Map outputSchema = Map.of("type", "object", "properties", - Map.of("result", Map.of("type", "number")), "required", List.of("result")); - - Tool calculatorTool = Tool.builder() - .name("calculator") - .description("Performs mathematical calculations") - .outputSchema(outputSchema) - .build(); - - McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() - .tool(calculatorTool) - .callHandler((exchange, request) -> { - // Return result without structured content but tool has output schema - return CallToolResult.builder().addTextContent("Calculation completed").build(); - }) - .build(); - - var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(tool) - .build(); - - try (var mcpClient = clientBuilder.build()) { - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Call tool that should return structured content but doesn't - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); - - assertThat(response).isNotNull(); - assertThat(response.isError()).isTrue(); - assertThat(response.content()).hasSize(1); - assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - - String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); - assertThat(errorMessage).isEqualTo( - "Response missing structured content which is expected when calling tool with non-empty outputSchema"); - } - finally { - mcpServer.closeGracefully(); - } - } - - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testStructuredOutputRuntimeToolAddition(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - - // Start server without tools - var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - try (var mcpClient = clientBuilder.build()) { - InitializeResult initResult = mcpClient.initialize(); - assertThat(initResult).isNotNull(); - - // Initially no tools - assertThat(mcpClient.listTools().tools()).isEmpty(); - - // Add tool with output schema at runtime - Map outputSchema = Map.of("type", "object", "properties", - Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required", - List.of("message", "count")); - - Tool dynamicTool = Tool.builder() - .name("dynamic-tool") - .description("Dynamically added tool") - .outputSchema(outputSchema) - .build(); - - McpServerFeatures.SyncToolSpecification toolSpec = McpServerFeatures.SyncToolSpecification.builder() - .tool(dynamicTool) - .callHandler((exchange, request) -> { - int count = (Integer) request.arguments().getOrDefault("count", 1); - return CallToolResult.builder() - .addTextContent("Dynamic tool executed " + count + " times") - .structuredContent(Map.of("message", "Dynamic execution", "count", count)) - .build(); - }) - .build(); - - // Add tool to server - mcpServer.addTool(toolSpec); - - // Wait for tool list change notification - await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { - assertThat(mcpClient.listTools().tools()).hasSize(1); - }); - - // Verify tool was added with output schema - var toolsList = mcpClient.listTools(); - assertThat(toolsList.tools()).hasSize(1); - assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool"); - // Note: outputSchema might be null in sync server, but validation still works - - // Call dynamically added tool - CallToolResult response = mcpClient - .callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3))); - - assertThat(response).isNotNull(); - assertThat(response.isError()).isFalse(); - - assertThat(response.content()).hasSize(1); - assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class); - assertThat(((McpSchema.TextContent) response.content().get(0)).text()) - .isEqualTo("Dynamic tool executed 3 times"); - - assertThat(response.structuredContent()).isNotNull(); - assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) - .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) - .isObject() - .isEqualTo(json(""" - {"count":3,"message":"Dynamic execution"}""")); - } - finally { - mcpServer.closeGracefully(); - } - } - - private double evaluateExpression(String expression) { - // Simple expression evaluator for testing - return switch (expression) { - case "2 + 3" -> 5.0; - case "10 * 2" -> 20.0; - case "7 + 8" -> 15.0; - case "5 + 3" -> 8.0; - default -> 0.0; - }; - } - -} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java deleted file mode 100644 index 915c658e3..000000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ /dev/null @@ -1,678 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server; - -import java.util.List; - -import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; -import io.modelcontextprotocol.spec.McpSchema.Prompt; -import io.modelcontextprotocol.spec.McpSchema.PromptMessage; -import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; -import io.modelcontextprotocol.spec.McpSchema.Resource; -import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; -import io.modelcontextprotocol.spec.McpSchema.Tool; -import io.modelcontextprotocol.spec.McpServerTransportProvider; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Test suite for the {@link McpSyncServer} that can be used with different - * {@link McpServerTransportProvider} implementations. - * - * @author Christian Tzolov - */ -// KEEP IN SYNC with the class in mcp-test module -public abstract class AbstractMcpSyncServerTests { - - private static final String TEST_TOOL_NAME = "test-tool"; - - private static final String TEST_RESOURCE_URI = "test://resource"; - - private static final String TEST_PROMPT_NAME = "test-prompt"; - - abstract protected McpServer.SyncSpecification prepareSyncServerBuilder(); - - protected void onStart() { - } - - protected void onClose() { - } - - @BeforeEach - void setUp() { - // onStart(); - } - - @AfterEach - void tearDown() { - onClose(); - } - - // --------------------------------------- - // Server Lifecycle Tests - // --------------------------------------- - - @Test - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpServer.sync((McpServerTransportProvider) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Transport provider must not be null"); - - assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Server info must not be null"); - } - - @Test - void testGracefulShutdown() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testImmediateClose() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::close).doesNotThrowAnyException(); - } - - @Test - void testGetAsyncServer() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThat(mcpSyncServer.getAsyncServer()).isNotNull(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // 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") - .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(McpServerFeatures.SyncToolSpecification.builder() - .tool(newTool) - .callHandler((exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build())).doesNotThrowAnyException(); - - 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() - .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()) - .toolCall(duplicateTool, - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.addTool(McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build())).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testDuplicateToolCallDuringBuilding() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("duplicate-build-toolcall") - .title("Duplicate toolcall during building") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(duplicateTool, - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .toolCall(duplicateTool, - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) // Duplicate! - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'duplicate-build-toolcall' is already registered."); - } - - @Test - void testDuplicateToolsInBatchListRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-list-tool") - .title("Duplicate tool in batch list") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - List specs = List.of( - McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler( - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(), - McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler( - (exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build() // Duplicate! - ); - - assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(specs) - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'batch-list-tool' is already registered."); - } - - @Test - void testDuplicateToolsInBatchVarargsRegistration() { - Tool duplicateTool = McpSchema.Tool.builder() - .name("batch-varargs-tool") - .title("Duplicate tool in batch varargs") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .tools(McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(), - McpServerFeatures.SyncToolSpecification.builder() - .tool(duplicateTool) - .callHandler((exchange, - request) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build() // Duplicate! - ) - .build()).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Tool with name 'batch-varargs-tool' is already registered."); - } - - @Test - void testRemoveTool() { - Tool tool = McpSchema.Tool.builder() - .name(TEST_TOOL_NAME) - .title("Test tool") - .inputSchema(EMPTY_JSON_SCHEMA) - .build(); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .toolCall(tool, (exchange, args) -> CallToolResult.builder().content(List.of()).isError(false).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.removeTool(TEST_TOOL_NAME)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentTool() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().tools(true).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.removeTool("nonexistent-tool")).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testNotifyToolsListChanged() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::notifyToolsListChanged).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Resources Tests - // --------------------------------------- - - @Test - void testNotifyResourcesListChanged() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::notifyResourcesListChanged).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testNotifyResourcesUpdated() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(() -> mcpSyncServer - .notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(TEST_RESOURCE_URI))) - .doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddResource() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); - - assertThatCode(() -> mcpSyncServer.addResource(specification)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddResourceWithNullSpecification() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - assertThatThrownBy(() -> mcpSyncServer.addResource((McpServerFeatures.SyncResourceSpecification) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Resource must not be null"); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddResourceWithoutCapability() { - var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); - - assertThatThrownBy(() -> serverWithoutResources.addResource(specification)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - } - - @Test - void testRemoveResourceWithoutCapability() { - var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - } - - @Test - void testListResources() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); - - mcpSyncServer.addResource(specification); - List resources = mcpSyncServer.listResources(); - - assertThat(resources).hasSize(1); - assertThat(resources.get(0).uri()).isEqualTo(TEST_RESOURCE_URI); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveResource() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - Resource resource = Resource.builder() - .uri(TEST_RESOURCE_URI) - .name("Test Resource") - .mimeType("text/plain") - .description("Test resource description") - .build(); - McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification( - resource, (exchange, req) -> new ReadResourceResult(List.of())); - - mcpSyncServer.addResource(specification); - assertThatCode(() -> mcpSyncServer.removeResource(TEST_RESOURCE_URI)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentResource() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - // Removing a non-existent resource should complete successfully (no error) - // as per the new implementation that just logs a warning - assertThatCode(() -> mcpSyncServer.removeResource("nonexistent://resource")).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Resource Template Tests - // --------------------------------------- - - @Test - void testAddResourceTemplate() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); - - assertThatCode(() -> mcpSyncServer.addResourceTemplate(specification)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddResourceTemplateWithoutCapability() { - // Create a server without resource capabilities - var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); - - assertThatThrownBy(() -> serverWithoutResources.addResourceTemplate(specification)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - } - - @Test - void testRemoveResourceTemplate() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .resourceTemplates(specification) - .build(); - - assertThatCode(() -> mcpSyncServer.removeResourceTemplate("test://template/{id}")).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveResourceTemplateWithoutCapability() { - // Create a server without resource capabilities - var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatThrownBy(() -> serverWithoutResources.removeResourceTemplate("test://template/{id}")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Server must be configured with resource capabilities"); - } - - @Test - void testRemoveNonexistentResourceTemplate() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.removeResourceTemplate("nonexistent://template/{id}")) - .doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testListResourceTemplates() { - McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate.builder() - .uriTemplate("test://template/{id}") - .name("test-template") - .description("Test resource template") - .mimeType("text/plain") - .build(); - - McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( - template, (exchange, req) -> new ReadResourceResult(List.of())); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .resourceTemplates(specification) - .build(); - - List templates = mcpSyncServer.listResourceTemplates(); - - assertThat(templates).isNotNull(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Prompts Tests - // --------------------------------------- - - @Test - void testNotifyPromptsListChanged() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatCode(mcpSyncServer::notifyPromptsListChanged).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testAddPromptWithNullSpecification() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(false).build()) - .build(); - - assertThatThrownBy(() -> mcpSyncServer.addPrompt((McpServerFeatures.SyncPromptSpecification) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Prompt specification must not be null"); - } - - @Test - void testAddPromptWithoutCapability() { - var serverWithoutPrompts = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", "Test Prompt", List.of()); - McpServerFeatures.SyncPromptSpecification specification = new McpServerFeatures.SyncPromptSpecification(prompt, - (exchange, req) -> new GetPromptResult("Test prompt description", List - .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); - - assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(specification)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("Server must be configured with prompt capabilities"); - } - - @Test - void testRemovePromptWithoutCapability() { - var serverWithoutPrompts = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("Server must be configured with prompt capabilities"); - } - - @Test - void testRemovePrompt() { - Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", "Test Prompt", List.of()); - McpServerFeatures.SyncPromptSpecification specification = new McpServerFeatures.SyncPromptSpecification(prompt, - (exchange, req) -> new GetPromptResult("Test prompt description", List - .of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))); - - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(true).build()) - .prompts(specification) - .build(); - - assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - @Test - void testRemoveNonexistentPrompt() { - var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().prompts(true).build()) - .build(); - - assertThatCode(() -> mcpSyncServer.removePrompt("nonexistent://template/{id}")).doesNotThrowAnyException(); - - assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); - } - - // --------------------------------------- - // Roots Tests - // --------------------------------------- - - @Test - void testRootsChangeHandlers() { - // Test with single consumer - var rootsReceived = new McpSchema.Root[1]; - var consumerCalled = new boolean[1]; - - var singleConsumerServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> { - consumerCalled[0] = true; - if (!roots.isEmpty()) { - rootsReceived[0] = roots.get(0); - } - })) - .build(); - assertThat(singleConsumerServer).isNotNull(); - assertThatCode(singleConsumerServer::closeGracefully).doesNotThrowAnyException(); - onClose(); - - // Test with multiple consumers - var consumer1Called = new boolean[1]; - var consumer2Called = new boolean[1]; - var rootsContent = new List[1]; - - var multipleConsumersServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> { - consumer1Called[0] = true; - rootsContent[0] = roots; - }, (exchange, roots) -> consumer2Called[0] = true)) - .build(); - - assertThat(multipleConsumersServer).isNotNull(); - assertThatCode(multipleConsumersServer::closeGracefully).doesNotThrowAnyException(); - onClose(); - - // Test error handling - var errorHandlingServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") - .rootsChangeHandlers(List.of((exchange, roots) -> { - throw new RuntimeException("Test error"); - })) - .build(); - - assertThat(errorHandlingServer).isNotNull(); - assertThatCode(errorHandlingServer::closeGracefully).doesNotThrowAnyException(); - onClose(); - - // Test without consumers - var noConsumersServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build(); - - assertThat(noConsumersServer).isNotNull(); - assertThatCode(noConsumersServer::closeGracefully).doesNotThrowAnyException(); - } - -} 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 62332fcdb..897ae2ccc 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java @@ -4,23 +4,33 @@ package io.modelcontextprotocol.server; -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - import java.util.List; import java.util.Map; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import io.modelcontextprotocol.spec.McpSchema; -import org.junit.jupiter.api.Test; - import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.TextContent; import io.modelcontextprotocol.spec.McpSchema.Tool; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import io.modelcontextprotocol.util.ToolNameValidator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + /** * Tests for {@link McpServerFeatures.AsyncToolSpecification.Builder}. * @@ -46,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 @@ -97,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()); @@ -117,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() @@ -190,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")); @@ -206,62 +161,76 @@ 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(); } + @Nested + class ToolNameValidation { + + private McpServerTransportProvider transportProvider; + + private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class); + + private final ListAppender logAppender = new ListAppender<>(); + + @BeforeEach + void setUp() { + transportProvider = mock(McpServerTransportProvider.class); + System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + logAppender.start(); + logger.addAppender(logAppender); + } + + @AfterEach + void tearDown() { + System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + logger.detachAppender(logAppender); + logAppender.stop(); + } + + @Test + void defaultShouldThrowOnInvalidName() { + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatThrownBy( + () -> McpServer.async(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + + @Test + void lenientDefaultShouldLogOnInvalidName() { + System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatCode(() -> McpServer.async(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) + .doesNotThrowAnyException(); + assertThat(logAppender.list).hasSize(1); + } + + @Test + void lenientConfigurationShouldLogOnInvalidName() { + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatCode(() -> McpServer.async(transportProvider) + .strictToolNameValidation(false) + .toolCall(invalidTool, (exchange, request) -> null)).doesNotThrowAnyException(); + assertThat(logAppender.list).hasSize(1); + } + + @Test + void serverConfigurationShouldOverrideDefault() { + System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatThrownBy(() -> McpServer.async(transportProvider) + .strictToolNameValidation(true) + .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 9bcd2bc84..54c45e561 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java @@ -4,19 +4,29 @@ package io.modelcontextprotocol.server; -import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - import java.util.List; import java.util.Map; -import org.junit.jupiter.api.Test; - +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.TextContent; import io.modelcontextprotocol.spec.McpSchema.Tool; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import io.modelcontextprotocol.util.ToolNameValidator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; /** * Tests for {@link McpServerFeatures.SyncToolSpecification.Builder}. @@ -41,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 @@ -102,4 +111,71 @@ void builtSpecificationShouldExecuteCallToolCorrectly() { assertThat(result.isError()).isFalse(); } + @Nested + class ToolNameValidation { + + private McpServerTransportProvider transportProvider; + + private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class); + + private final ListAppender logAppender = new ListAppender<>(); + + @BeforeEach + void setUp() { + transportProvider = mock(McpServerTransportProvider.class); + System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + logAppender.start(); + logger.addAppender(logAppender); + } + + @AfterEach + void tearDown() { + System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); + logger.detachAppender(logAppender); + logAppender.stop(); + } + + @Test + void defaultShouldThrowOnInvalidName() { + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatThrownBy( + () -> McpServer.sync(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + + @Test + void lenientDefaultShouldLogOnInvalidName() { + System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatCode(() -> McpServer.sync(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) + .doesNotThrowAnyException(); + assertThat(logAppender.list).hasSize(1); + } + + @Test + void lenientConfigurationShouldLogOnInvalidName() { + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatCode(() -> McpServer.sync(transportProvider) + .strictToolNameValidation(false) + .toolCall(invalidTool, (exchange, request) -> null)).doesNotThrowAnyException(); + assertThat(logAppender.list).hasSize(1); + } + + @Test + void serverConfigurationShouldOverrideDefault() { + System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); + Tool invalidTool = Tool.builder().name("invalid tool name").build(); + + assertThatThrownBy(() -> McpServer.sync(transportProvider) + .strictToolNameValidation(true) + .toolCall(invalidTool, (exchange, request) -> null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + + } + } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java new file mode 100644 index 000000000..d4cf8582d --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java @@ -0,0 +1,424 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Daniel Garnier-Moiroux + */ +class DefaultServerTransportSecurityValidatorTests { + + private static final ServerTransportSecurityException INVALID_ORIGIN = new ServerTransportSecurityException(403, + "Invalid Origin header"); + + private static final ServerTransportSecurityException INVALID_HOST = new ServerTransportSecurityException(421, + "Invalid Host header"); + + private final DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:8080") + .build(); + + @Test + void builder() { + assertThatCode(() -> DefaultServerTransportSecurityValidator.builder().build()).doesNotThrowAnyException(); + assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedOrigins(null).build()) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> DefaultServerTransportSecurityValidator.builder().allowedHosts(null).build()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Nested + class OriginHeader { + + @Test + void originHeaderMissing() { + assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); + } + + @Test + void originHeaderListEmpty() { + assertThatThrownBy(() -> validator.validateHeaders(Map.of("Origin", List.of()))).isEqualTo(INVALID_ORIGIN); + } + + @Test + void caseInsensitive() { + var headers = Map.of("origin", List.of("http://localhost:8080")); + + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void exactMatch() { + var headers = originHeader("http://localhost:8080"); + + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentPort() { + + var headers = originHeader("http://localhost:3000"); + + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void differentHost() { + + var headers = originHeader("http://example.com:8080"); + + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void differentScheme() { + + var headers = originHeader("https://localhost:8080"); + + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Nested + class WildcardPort { + + private final DefaultServerTransportSecurityValidator wildcardValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedOrigin("http://localhost:*") + .build(); + + @Test + void anyPortWithWildcard() { + var headers = originHeader("http://localhost:3000"); + + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void noPortWithWildcard() { + var headers = originHeader("http://localhost"); + + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentPortWithWildcard() { + var headers = originHeader("http://localhost:8080"); + + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentHostWithWildcard() { + var headers = originHeader("http://example.com:3000"); + + assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void differentSchemeWithWildcard() { + var headers = originHeader("https://localhost:3000"); + + assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + } + + @Nested + class MultipleOrigins { + + DefaultServerTransportSecurityValidator multipleOriginsValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedOrigin("http://localhost:8080") + .allowedOrigin("http://example.com:3000") + .allowedOrigin("http://myapp.example.com:*") + .build(); + + @Test + void matchingOneOfMultiple() { + var headers = originHeader("http://example.com:3000"); + + assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void matchingWildcardInMultiple() { + var headers = originHeader("http://myapp.example.com:9999"); + + assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void notMatchingAny() { + var headers = originHeader("http://malicious.example.com:1234"); + + assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + } + + @Nested + class BuilderTests { + + @Test + void shouldAddMultipleOriginsWithAllowedOriginsMethod() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedOrigins(List.of("http://localhost:8080", "http://example.com:*")) + .build(); + + var headers = originHeader("http://example.com:3000"); + + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void shouldCombineAllowedOriginMethods() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:8080") + .allowedOrigins(List.of("http://example.com:*", "http://test.com:3000")) + .build(); + + assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000"))) + .doesNotThrowAnyException(); + } + + } + + } + + @Nested + class HostHeader { + + private final DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedHost("localhost:8080") + .build(); + + @Test + void notConfigured() { + assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); + } + + @Test + void missing() { + assertThatThrownBy(() -> hostValidator.validateHeaders(new HashMap<>())).isEqualTo(INVALID_HOST); + } + + @Test + void listEmpty() { + assertThatThrownBy(() -> hostValidator.validateHeaders(Map.of("Host", List.of()))).isEqualTo(INVALID_HOST); + } + + @Test + void caseInsensitive() { + var headers = Map.of("host", List.of("localhost:8080")); + + assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void exactMatch() { + var headers = hostHeader("localhost:8080"); + + assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentPort() { + var headers = hostHeader("localhost:3000"); + + assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + @Test + void differentHost() { + var headers = hostHeader("example.com:8080"); + + assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + @Nested + class HostWildcardPort { + + private final DefaultServerTransportSecurityValidator wildcardHostValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedHost("localhost:*") + .build(); + + @Test + void anyPort() { + var headers = hostHeader("localhost:3000"); + + assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void noPort() { + var headers = hostHeader("localhost"); + + assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentHost() { + var headers = hostHeader("example.com:3000"); + + assertThatThrownBy(() -> wildcardHostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + } + + @Nested + class MultipleHosts { + + DefaultServerTransportSecurityValidator multipleHostsValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedHost("example.com:3000") + .allowedHost("myapp.example.com:*") + .build(); + + @Test + void exactMatch() { + var headers = hostHeader("example.com:3000"); + + assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void wildcard() { + var headers = hostHeader("myapp.example.com:9999"); + + assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void differentHost() { + var headers = hostHeader("malicious.example.com:3000"); + + assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + @Test + void differentPort() { + var headers = hostHeader("localhost:8080"); + + assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + } + + @Nested + class HostBuilderTests { + + @Test + void multipleHosts() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedHosts(List.of("localhost:8080", "example.com:*")) + .build(); + + assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:3000"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080"))) + .doesNotThrowAnyException(); + } + + @Test + void combined() { + DefaultServerTransportSecurityValidator validator = DefaultServerTransportSecurityValidator.builder() + .allowedHost("localhost:8080") + .allowedHosts(List.of("example.com:*", "test.com:3000")) + .build(); + + assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:9999"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("test.com:3000"))).doesNotThrowAnyException(); + } + + } + + } + + @Nested + class CombinedOriginAndHostValidation { + + private final DefaultServerTransportSecurityValidator combinedValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build(); + + @Test + void bothValid() { + var header = headers("http://localhost:8080", "localhost:8080"); + + assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); + } + + @Test + void originValidHostInvalid() { + var header = headers("http://localhost:8080", "malicious.example.com:8080"); + + assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); + } + + @Test + void originInvalidHostValid() { + var header = headers("http://malicious.example.com:8080", "localhost:8080"); + + assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void originMissingHostValid() { + // Origin missing is OK (same-origin request) + var header = headers(null, "localhost:8080"); + + assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); + } + + @Test + void originValidHostMissing() { + // Host missing is NOT OK when allowedHosts is configured + var header = headers("http://localhost:8080", null); + + assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); + } + + } + + private static Map> originHeader(String origin) { + return Map.of("Origin", List.of(origin)); + } + + private static Map> hostHeader(String host) { + return Map.of("Host", List.of(host)); + } + + private static Map> headers(String origin, String host) { + var map = new HashMap>(); + if (origin != null) { + map.put("Origin", List.of(origin)); + } + if (host != null) { + map.put("Host", List.of(host)); + } + return map; + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java index 911506e01..803372056 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java @@ -1,5 +1,6 @@ package io.modelcontextprotocol.util; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; public final class McpJsonMapperUtils { @@ -7,6 +8,6 @@ public final class McpJsonMapperUtils { private McpJsonMapperUtils() { } - public static final McpJsonMapper JSON_MAPPER = McpJsonMapper.getDefault(); + public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java new file mode 100644 index 000000000..f8e301f82 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolNameValidatorTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.util; + +import java.util.List; +import java.util.function.Consumer; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.LoggerFactory; + +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 {@link ToolNameValidator}. + */ +class ToolNameValidatorTests { + + private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class); + + private final ListAppender logAppender = new ListAppender<>(); + + @BeforeEach + void setUp() { + logAppender.start(); + logger.addAppender(logAppender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(logAppender); + logAppender.stop(); + } + + @ParameterizedTest + @ValueSource(strings = { "getUser", "DATA_EXPORT_v2", "admin.tools.list", "my-tool", "Tool123", "a", "A", + "_private", "tool_name", "tool-name", "tool.name", "UPPERCASE", "lowercase", "MixedCase123" }) + void validToolNames(String name) { + assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException(); + ToolNameValidator.validate(name, false); + assertThat(logAppender.list).isEmpty(); + } + + @Test + void validToolNameMaxLength() { + String name = "a".repeat(128); + assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException(); + ToolNameValidator.validate(name, false); + assertThat(logAppender.list).isEmpty(); + } + + @Test + void nullOrEmpty() { + assertThatThrownBy(() -> ToolNameValidator.validate(null, true)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("null or empty"); + assertThatThrownBy(() -> ToolNameValidator.validate("", true)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("null or empty"); + } + + @Test + void strictLength() { + String name = "a".repeat(129); + assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("128 characters"); + } + + @ParameterizedTest + @ValueSource(strings = { "tool name", // space + "tool,name", // comma + "tool@name", // at sign + "tool#name", // hash + "tool$name", // dollar + "tool%name", // percent + "tool&name", // ampersand + "tool*name", // asterisk + "tool+name", // plus + "tool=name", // equals + "tool/name", // slash + "tool\\name", // backslash + "tool:name", // colon + "tool;name", // semicolon + "tool'name", // single quote + "tool\"name", // double quote + "toolname", // greater than + "tool?name", // question mark + "tool!name", // exclamation + "tool(name)", // parentheses + "tool[name]", // brackets + "tool{name}", // braces + "tool|name", // pipe + "tool~name", // tilde + "tool`name", // backtick + "tool^name", // caret + "tööl", // non-ASCII + "工具" // unicode + }) + void strictInvalidCharacters(String name) { + assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + + @Test + void lenientNull() { + assertThatCode(() -> ToolNameValidator.validate(null, false)).doesNotThrowAnyException(); + assertThat(logAppender.list).satisfies(hasWarning("null or empty")); + } + + @Test + void lenientEmpty() { + assertThatCode(() -> ToolNameValidator.validate("", false)).doesNotThrowAnyException(); + assertThat(logAppender.list).satisfies(hasWarning("null or empty")); + } + + @Test + void lenientLength() { + assertThatCode(() -> ToolNameValidator.validate("a".repeat(129), false)).doesNotThrowAnyException(); + assertThat(logAppender.list).satisfies(hasWarning("128 characters")); + } + + @Test + void lenientInvalidCharacters() { + assertThatCode(() -> ToolNameValidator.validate("invalid name", false)).doesNotThrowAnyException(); + assertThat(logAppender.list).satisfies(hasWarning("invalid characters")); + } + + private Consumer> hasWarning(String errorMessage) { + return logs -> { + assertThat(logs).hasSize(1).first().satisfies(log -> { + assertThat(log.getLevel()).isEqualTo(Level.WARN); + assertThat(log.getFormattedMessage()).contains(errorMessage); + }); + }; + } + +} diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index de2ac58ce..f25877cd3 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,12 +6,12 @@ io.modelcontextprotocol.sdk mcp-parent - 0.18.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp-json-jackson2 jar - Java MCP SDK JSON Jackson - Java MCP SDK JSON implementation based on Jackson + Java MCP SDK JSON Jackson 2 + Java MCP SDK JSON implementation based on Jackson 2 https://github.com/modelcontextprotocol/java-sdk https://github.com/modelcontextprotocol/java-sdk @@ -20,34 +20,62 @@ + + biz.aQute.bnd + bnd-maven-plugin + ${bnd-maven-plugin.version} + + + bnd-process + + bnd-process + + + + + + + + + org.apache.maven.plugins maven-jar-plugin - - true - + ${project.build.outputDirectory}/META-INF/MANIFEST.MF - - io.modelcontextprotocol.sdk - mcp-json - 0.18.0-SNAPSHOT - com.fasterxml.jackson.core jackson-databind - ${jackson.version} + ${jackson2.version} + + + io.modelcontextprotocol.sdk + mcp-core + 1.1.0-SNAPSHOT com.networknt json-schema-validator - ${json-schema-validator.version} + ${json-schema-validator-jackson2.version} 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/jackson2/JacksonMcpJsonMapper.java similarity index 96% rename from mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapper.java rename to mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapper.java index 6aa2b4ebc..1760cf472 100644 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapper.java +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapper.java @@ -1,16 +1,17 @@ /* - * Copyright 2025 - 2025 the original author or authors. + * Copyright 2026 - 2026 the original author or authors. */ -package io.modelcontextprotocol.json.jackson; +package io.modelcontextprotocol.json.jackson2; + +import java.io.IOException; 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. 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/jackson2/JacksonMcpJsonMapperSupplier.java similarity index 89% rename from mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java rename to mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapperSupplier.java index 0e79c3e0e..acd5dddaa 100644 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson2/JacksonMcpJsonMapperSupplier.java @@ -1,8 +1,8 @@ /* - * Copyright 2025 - 2025 the original author or authors. + * Copyright 2026 - 2026 the original author or authors. */ -package io.modelcontextprotocol.json.jackson; +package io.modelcontextprotocol.json.jackson2; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.McpJsonMapperSupplier; 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/jackson2/DefaultJsonSchemaValidator.java similarity index 97% rename from mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/DefaultJsonSchemaValidator.java rename to mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/DefaultJsonSchemaValidator.java index 1ff28cb80..e07bf1759 100644 --- 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/jackson2/DefaultJsonSchemaValidator.java @@ -1,22 +1,24 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2026-2026 the original author or authors. */ -package io.modelcontextprotocol.json.schema.jackson; +package io.modelcontextprotocol.json.schema.jackson2; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.Error; 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 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/jackson2/JacksonJsonSchemaValidatorSupplier.java similarity index 87% rename from mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson/JacksonJsonSchemaValidatorSupplier.java rename to mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/schema/jackson2/JacksonJsonSchemaValidatorSupplier.java index 86153a538..aa280a38e 100644 --- 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/jackson2/JacksonJsonSchemaValidatorSupplier.java @@ -1,8 +1,8 @@ /* - * Copyright 2025 - 2025 the original author or authors. + * Copyright 2026 - 2026 the original author or authors. */ -package io.modelcontextprotocol.json.schema.jackson; +package io.modelcontextprotocol.json.schema.jackson2; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier; diff --git a/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier b/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier index 8ea66d698..0c62b6478 100644 --- a/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier +++ b/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier @@ -1 +1 @@ -io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapperSupplier \ No newline at end of file +io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier \ No newline at end of file diff --git a/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier b/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier index 0fb0b7e5a..1b2f05f97 100644 --- a/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier +++ b/mcp-json-jackson2/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier @@ -1 +1 @@ -io.modelcontextprotocol.json.schema.jackson.JacksonJsonSchemaValidatorSupplier \ No newline at end of file +io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier \ No newline at end of file diff --git a/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier.xml b/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier.xml new file mode 100644 index 000000000..1d6705f56 --- /dev/null +++ b/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapperSupplier.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier.xml b/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier.xml new file mode 100644 index 000000000..ad628745f --- /dev/null +++ b/mcp-json-jackson2/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson2.JacksonJsonSchemaValidatorSupplier.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java new file mode 100644 index 000000000..7ae5d0887 --- /dev/null +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java @@ -0,0 +1,20 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; + +class McpJsonMapperTest { + + @Test + void shouldUseJackson2Mapper() { + assertThat(McpJsonDefaults.getMapper()).isInstanceOf(JacksonMcpJsonMapper.class); + } + +} diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java similarity index 99% rename from mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java rename to mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java index 7642f0480..5ae3fbed4 100644 --- a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson2/DefaultJsonSchemaValidatorTests.java @@ -1,8 +1,8 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2026-2026 the original author or authors. */ -package io.modelcontextprotocol.json; +package io.modelcontextprotocol.json.jackson2; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -17,7 +17,6 @@ import java.util.Map; import java.util.stream.Stream; -import io.modelcontextprotocol.json.schema.jackson.DefaultJsonSchemaValidator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -30,6 +29,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; +import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; /** * Tests for {@link DefaultJsonSchemaValidator}. diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java new file mode 100644 index 000000000..92a80cb9b --- /dev/null +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java @@ -0,0 +1,21 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.schema; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator; + +class JsonSchemaValidatorTest { + + @Test + void shouldUseJackson2Mapper() { + assertThat(McpJsonDefaults.getSchemaValidator()).isInstanceOf(DefaultJsonSchemaValidator.class); + } + +} diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml new file mode 100644 index 000000000..99baf14e1 --- /dev/null +++ b/mcp-json-jackson3/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + io.modelcontextprotocol.sdk + mcp-parent + 1.1.0-SNAPSHOT + + mcp-json-jackson3 + jar + Java MCP SDK JSON Jackson 3 + Java MCP SDK JSON implementation based on Jackson 3 + 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 + + + + + biz.aQute.bnd + bnd-maven-plugin + ${bnd-maven-plugin.version} + + + bnd-process + + bnd-process + + + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + + + + io.modelcontextprotocol.sdk + mcp-core + 1.1.0-SNAPSHOT + + + tools.jackson.core + jackson-databind + ${jackson3.version} + + + com.networknt + json-schema-validator + ${json-schema-validator-jackson3.version} + + + + org.assertj + assertj-core + ${assert4j.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + + diff --git a/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapper.java b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapper.java new file mode 100644 index 000000000..a0dbdd555 --- /dev/null +++ b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapper.java @@ -0,0 +1,119 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.jackson3; + +import java.io.IOException; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.json.JsonMapper; + +/** + * Jackson-based implementation of JsonMapper. Wraps a Jackson JsonMapper but keeps the + * SDK decoupled from Jackson at the API level. + */ +public final class JacksonMcpJsonMapper implements McpJsonMapper { + + private final JsonMapper jsonMapper; + + /** + * Constructs a new JacksonMcpJsonMapper instance with the given JsonMapper. + * @param jsonMapper the JsonMapper to be used for JSON serialization and + * deserialization. Must not be null. + * @throws IllegalArgumentException if the provided JsonMapper is null. + */ + public JacksonMcpJsonMapper(JsonMapper jsonMapper) { + if (jsonMapper == null) { + throw new IllegalArgumentException("JsonMapper must not be null"); + } + this.jsonMapper = jsonMapper; + } + + /** + * Returns the underlying Jackson {@link JsonMapper} used for JSON serialization and + * deserialization. + * @return the JsonMapper instance + */ + public JsonMapper getJsonMapper() { + return jsonMapper; + } + + @Override + public T readValue(String content, Class type) throws IOException { + try { + return jsonMapper.readValue(content, type); + } + catch (JacksonException ex) { + throw new IOException("Failed to read value", ex); + } + } + + @Override + public T readValue(byte[] content, Class type) throws IOException { + try { + return jsonMapper.readValue(content, type); + } + catch (JacksonException ex) { + throw new IOException("Failed to read value", ex); + } + } + + @Override + public T readValue(String content, TypeRef type) throws IOException { + JavaType javaType = jsonMapper.getTypeFactory().constructType(type.getType()); + try { + return jsonMapper.readValue(content, javaType); + } + catch (JacksonException ex) { + throw new IOException("Failed to read value", ex); + } + } + + @Override + public T readValue(byte[] content, TypeRef type) throws IOException { + JavaType javaType = jsonMapper.getTypeFactory().constructType(type.getType()); + try { + return jsonMapper.readValue(content, javaType); + } + catch (JacksonException ex) { + throw new IOException("Failed to read value", ex); + } + } + + @Override + public T convertValue(Object fromValue, Class type) { + return jsonMapper.convertValue(fromValue, type); + } + + @Override + public T convertValue(Object fromValue, TypeRef type) { + JavaType javaType = jsonMapper.getTypeFactory().constructType(type.getType()); + return jsonMapper.convertValue(fromValue, javaType); + } + + @Override + public String writeValueAsString(Object value) throws IOException { + try { + return jsonMapper.writeValueAsString(value); + } + catch (JacksonException ex) { + throw new IOException("Failed to write value as string", ex); + } + } + + @Override + public byte[] writeValueAsBytes(Object value) throws IOException { + try { + return jsonMapper.writeValueAsBytes(value); + } + catch (JacksonException ex) { + throw new IOException("Failed to write value as bytes", ex); + } + } + +} diff --git a/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapperSupplier.java b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapperSupplier.java new file mode 100644 index 000000000..839862ffe --- /dev/null +++ b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/jackson3/JacksonMcpJsonMapperSupplier.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.jackson3; + +import tools.jackson.databind.json.JsonMapper; + +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 + * {@link JsonMapper#shared() JsonMapper shared instance}. + */ +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 {@link JsonMapper#shared() + * JsonMapper shared instance}. + * @return a new {@link McpJsonMapper} instance + */ + @Override + public McpJsonMapper get() { + return new JacksonMcpJsonMapper(JsonMapper.shared()); + } + +} diff --git a/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java new file mode 100644 index 000000000..8c9b7ccdb --- /dev/null +++ b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/DefaultJsonSchemaValidator.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ +package io.modelcontextprotocol.json.schema.jackson3; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +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; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** + * 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 Filip Hrisafov + */ +public class DefaultJsonSchemaValidator implements JsonSchemaValidator { + + private static final Logger logger = LoggerFactory.getLogger(DefaultJsonSchemaValidator.class); + + private final JsonMapper jsonMapper; + + private final SchemaRegistry schemaFactory; + + // TODO: Implement a strategy to purge the cache (TTL, size limit, etc.) + private final ConcurrentHashMap schemaCache; + + public DefaultJsonSchemaValidator() { + this(JsonMapper.shared()); + } + + public DefaultJsonSchemaValidator(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + 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.jsonMapper.readTree((String) structuredContent) + : this.jsonMapper.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 (JacksonException 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 JacksonException if schema processing fails + */ + private Schema getOrCreateJsonSchema(Map schema) throws JacksonException { + // 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 JacksonException if schema processing fails + */ + private Schema createJsonSchema(Map schema) throws JacksonException { + // Convert schema map directly to JsonNode (more efficient than string + // serialization) + JsonNode schemaNode = this.jsonMapper.valueToTree(schema); + + // Handle case where ObjectMapper might return null (e.g., in mocked scenarios) + if (schemaNode == null) { + throw new JacksonException("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-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/JacksonJsonSchemaValidatorSupplier.java b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/JacksonJsonSchemaValidatorSupplier.java new file mode 100644 index 000000000..87cead5db --- /dev/null +++ b/mcp-json-jackson3/src/main/java/io/modelcontextprotocol/json/schema/jackson3/JacksonJsonSchemaValidatorSupplier.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.schema.jackson3; + +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 + */ +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-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier b/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier new file mode 100644 index 000000000..6abfb347f --- /dev/null +++ b/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier \ No newline at end of file diff --git a/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier b/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier new file mode 100644 index 000000000..2bab3ba8e --- /dev/null +++ b/mcp-json-jackson3/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier \ No newline at end of file diff --git a/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier.xml b/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier.xml new file mode 100644 index 000000000..0ad8a7b42 --- /dev/null +++ b/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapperSupplier.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier.xml b/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier.xml new file mode 100644 index 000000000..d14d8bea3 --- /dev/null +++ b/mcp-json-jackson3/src/main/resources/OSGI-INF/io.modelcontextprotocol.json.schema.jackson3.JacksonJsonSchemaValidatorSupplier.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java new file mode 100644 index 000000000..37c52caf7 --- /dev/null +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/DefaultJsonSchemaValidatorTests.java @@ -0,0 +1,807 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json; + +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 io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; +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 tools.jackson.core.type.TypeReference; +import tools.jackson.databind.json.JsonMapper; + +import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse; + +/** + * Tests for {@link DefaultJsonSchemaValidator}. + * + * @author Filip Hrisafov + */ +class DefaultJsonSchemaValidatorTests { + + private DefaultJsonSchemaValidator validator; + + private JsonMapper jsonMapper; + + @Mock + private JsonMapper mockJsonMapper; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + validator = new DefaultJsonSchemaValidator(); + jsonMapper = JsonMapper.shared(); + } + + /** + * Utility method to convert JSON string to Map + */ + private Map toMap(String json) { + try { + return jsonMapper.readValue(json, new TypeReference<>() { + }); + } + catch (Exception e) { + throw new RuntimeException("Failed to parse JSON: " + json, e); + } + } + + private List> toListMap(String json) { + try { + return jsonMapper.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() { + JsonMapper customMapper = JsonMapper.builder().build(); + 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() { + DefaultJsonSchemaValidator validatorWithMockMapper = new DefaultJsonSchemaValidator(mockJsonMapper); + + Map schema = Map.of("type", "object"); + Map structuredContent = Map.of("key", "value"); + + // This will trigger our null check and throw JsonProcessingException + when(mockJsonMapper.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 { + return JsonMapper.shared().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/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java new file mode 100644 index 000000000..0307fceb5 --- /dev/null +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/McpJsonMapperTest.java @@ -0,0 +1,20 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; + +class McpJsonMapperTest { + + @Test + void shouldUseJackson2Mapper() { + assertThat(McpJsonDefaults.getMapper()).isInstanceOf(JacksonMcpJsonMapper.class); + } + +} diff --git a/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java new file mode 100644 index 000000000..05dba4f42 --- /dev/null +++ b/mcp-json-jackson3/src/test/java/io/modelcontextprotocol/json/schema/JsonSchemaValidatorTest.java @@ -0,0 +1,21 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.json.schema; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator; + +class JsonSchemaValidatorTest { + + @Test + void shouldUseJackson2Mapper() { + assertThat(McpJsonDefaults.getSchemaValidator()).isInstanceOf(DefaultJsonSchemaValidator.class); + } + +} diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonInternal.java b/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonInternal.java deleted file mode 100644 index 31930ab33..000000000 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/McpJsonInternal.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2025 - 2025 the original author or authors. - */ - -package io.modelcontextprotocol.json; - -import java.util.ServiceLoader; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Stream; - -/** - * Utility class for creating a default {@link McpJsonMapper} instance. This class - * provides a single method to create a default mapper using the {@link ServiceLoader} - * mechanism. - */ -final class McpJsonInternal { - - private static McpJsonMapper defaultJsonMapper = null; - - /** - * Returns the cached default {@link McpJsonMapper} instance. If the default mapper - * has not been created yet, it will be initialized using the - * {@link #createDefaultMapper()} method. - * @return the default {@link McpJsonMapper} instance - * @throws IllegalStateException if no default {@link McpJsonMapper} implementation is - * found - */ - static McpJsonMapper getDefaultMapper() { - if (defaultJsonMapper == null) { - defaultJsonMapper = McpJsonInternal.createDefaultMapper(); - } - return defaultJsonMapper; - } - - /** - * Creates a default {@link McpJsonMapper} instance using the {@link ServiceLoader} - * mechanism. The default mapper is resolved by loading the first available - * {@link McpJsonMapperSupplier} implementation on the classpath. - * @return the default {@link McpJsonMapper} instance - * @throws IllegalStateException if no default {@link McpJsonMapper} implementation is - * found - */ - static McpJsonMapper createDefaultMapper() { - AtomicReference ex = new AtomicReference<>(); - return ServiceLoader.load(McpJsonMapperSupplier.class).stream().flatMap(p -> { - try { - McpJsonMapperSupplier supplier = p.get(); - return Stream.ofNullable(supplier); - } - catch (Exception e) { - addException(ex, e); - return Stream.empty(); - } - }).flatMap(jsonMapperSupplier -> { - try { - return Stream.ofNullable(jsonMapperSupplier.get()); - } - catch (Exception e) { - addException(ex, e); - return Stream.empty(); - } - }).findFirst().orElseThrow(() -> { - if (ex.get() != null) { - return ex.get(); - } - else { - return new IllegalStateException("No default McpJsonMapper implementation found"); - } - }); - } - - private static void addException(AtomicReference ref, Exception toAdd) { - ref.updateAndGet(existing -> { - if (existing == null) { - return new IllegalStateException("Failed to initialize default McpJsonMapper", toAdd); - } - else { - existing.addSuppressed(toAdd); - return existing; - } - }); - } - -} diff --git a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaInternal.java b/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaInternal.java deleted file mode 100644 index 2497e7f80..000000000 --- a/mcp-json/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaInternal.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2025 - 2025 the original author or authors. - */ - -package io.modelcontextprotocol.json.schema; - -import java.util.ServiceLoader; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Stream; - -/** - * Internal utility class for creating a default {@link JsonSchemaValidator} instance. - * This class uses the {@link ServiceLoader} to discover and instantiate a - * {@link JsonSchemaValidatorSupplier} implementation. - */ -final class JsonSchemaInternal { - - private static JsonSchemaValidator defaultValidator = null; - - /** - * Returns the default {@link JsonSchemaValidator} instance. If the default validator - * has not been initialized, it will be created using the {@link ServiceLoader} to - * discover and instantiate a {@link JsonSchemaValidatorSupplier} implementation. - * @return The default {@link JsonSchemaValidator} instance. - * @throws IllegalStateException If no {@link JsonSchemaValidatorSupplier} - * implementation exists on the classpath or if an error occurs during instantiation. - */ - static JsonSchemaValidator getDefaultValidator() { - if (defaultValidator == null) { - defaultValidator = JsonSchemaInternal.createDefaultValidator(); - } - return defaultValidator; - } - - /** - * Creates a default {@link JsonSchemaValidator} instance by loading a - * {@link JsonSchemaValidatorSupplier} implementation using the {@link ServiceLoader}. - * @return A default {@link JsonSchemaValidator} instance. - * @throws IllegalStateException If no {@link JsonSchemaValidatorSupplier} - * implementation is found or if an error occurs during instantiation. - */ - static JsonSchemaValidator createDefaultValidator() { - AtomicReference ex = new AtomicReference<>(); - return ServiceLoader.load(JsonSchemaValidatorSupplier.class).stream().flatMap(p -> { - try { - JsonSchemaValidatorSupplier supplier = p.get(); - return Stream.ofNullable(supplier); - } - catch (Exception e) { - addException(ex, e); - return Stream.empty(); - } - }).flatMap(jsonMapperSupplier -> { - try { - return Stream.of(jsonMapperSupplier.get()); - } - catch (Exception e) { - addException(ex, e); - return Stream.empty(); - } - }).findFirst().orElseThrow(() -> { - if (ex.get() != null) { - return ex.get(); - } - else { - return new IllegalStateException("No default JsonSchemaValidatorSupplier implementation found"); - } - }); - } - - private static void addException(AtomicReference ref, Exception toAdd) { - ref.updateAndGet(existing -> { - if (existing == null) { - return new IllegalStateException("Failed to initialize default JsonSchemaValidatorSupplier", toAdd); - } - else { - existing.addSuppressed(toAdd); - return existing; - } - }); - } - -} 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 f1737a477..000000000 --- a/mcp-spring/mcp-spring-webflux/pom.xml +++ /dev/null @@ -1,146 +0,0 @@ - - - 4.0.0 - - io.modelcontextprotocol.sdk - mcp-parent - 0.18.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-json-jackson2 - 0.18.0-SNAPSHOT - - - - io.modelcontextprotocol.sdk - mcp - 0.18.0-SNAPSHOT - - - - io.modelcontextprotocol.sdk - mcp-test - 0.18.0-SNAPSHOT - test - - - - org.springframework - spring-webflux - ${springframework.version} - - - - 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-jupiter.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 a8a4762c2..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebClientStreamableHttpTransport.java +++ /dev/null @@ -1,615 +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.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) { - 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) - .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(message) - .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) { - 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); - - 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 ? McpJsonMapper.getDefault() : 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 91b89d6d2..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/client/transport/WebFluxSseClientTransport.java +++ /dev/null @@ -1,412 +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.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 ? McpJsonMapper.getDefault() : 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 0c80c5b8b..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxSseServerTransportProvider.java +++ /dev/null @@ -1,530 +0,0 @@ -/* - * Copyright 2025-2025 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.concurrent.ConcurrentHashMap; - -import io.modelcontextprotocol.common.McpTransportContext; -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; - - /** - * 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. - * @throws IllegalArgumentException if either parameter is null - */ - private WebFluxSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint, - String sseEndpoint, Duration keepAliveInterval, - McpTransportContextExtractor contextExtractor) { - 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"); - - this.jsonMapper = jsonMapper; - this.baseUrl = baseUrl; - this.messageEndpoint = messageEndpoint; - this.sseEndpoint = sseEndpoint; - this.contextExtractor = contextExtractor; - 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"); - } - - 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"); - } - - 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; - - /** - * 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; - } - - /** - * 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 ? McpJsonMapper.getDefault() : jsonMapper, - baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor); - } - - } - -} 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 400be341e..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStatelessServerTransport.java +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package io.modelcontextprotocol.server.transport; - -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; - -/** - * 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; - - private WebFluxStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor contextExtractor) { - Assert.notNull(jsonMapper, "jsonMapper must not be null"); - Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null"); - Assert.notNull(contextExtractor, "contextExtractor must not be null"); - - this.jsonMapper = jsonMapper; - this.mcpEndpoint = mcpEndpoint; - this.contextExtractor = contextExtractor; - 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"); - } - - 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 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; - } - - /** - * 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 ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor); - } - - } - -} 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 deebfc616..000000000 --- a/mcp-spring/mcp-spring-webflux/src/main/java/io/modelcontextprotocol/server/transport/WebFluxStreamableServerTransportProvider.java +++ /dev/null @@ -1,495 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package io.modelcontextprotocol.server.transport; - -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.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; - - private WebFluxStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor contextExtractor, boolean disallowDelete, - Duration keepAliveInterval) { - 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"); - - this.jsonMapper = jsonMapper; - this.mcpEndpoint = mcpEndpoint; - this.contextExtractor = contextExtractor; - this.disallowDelete = disallowDelete; - 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); - } - - @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"); - } - - 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"); - } - - 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"); - } - - 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 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; - } - - /** - * 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 ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, contextExtractor, - disallowDelete, keepAliveInterval); - } - - } - -} 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 5d2bfda68..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_06_18)); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo(ProtocolVersions.MCP_2025_06_18); - mcpServer.close(); - } - - @Test - void usesServerSupportedVersion() { - var transport = WebClientStreamableHttpTransport - .builder(WebClient.builder().baseUrl("http://localhost:" + PORT)) - .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_06_18, "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_06_18)); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo(ProtocolVersions.MCP_2025_06_18); - 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 1a4eedd15..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpAsyncClientTests.java +++ /dev/null @@ -1,47 +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"; - - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js 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 16f1d79a6..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebClientStreamableHttpSyncClientTests.java +++ /dev/null @@ -1,47 +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"; - - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js 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 0a92beac4..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpAsyncClientTests.java +++ /dev/null @@ -1,58 +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"; - - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js 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/WebFluxSseMcpSyncClientTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java deleted file mode 100644 index 0f35f9f0d..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/client/WebFluxSseMcpSyncClientTests.java +++ /dev/null @@ -1,57 +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"; - - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image - @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js 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 e2fcf91f7..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/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js 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 1150e47f5..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.ObjectMapper; -import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.jackson.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/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js 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 - ObjectMapper customMapper = new ObjectMapper(); - 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/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 67347573c..000000000 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/utils/McpJsonMapperUtils.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.modelcontextprotocol.utils; - -import io.modelcontextprotocol.json.McpJsonMapper; - -public final class McpJsonMapperUtils { - - private McpJsonMapperUtils() { - } - - public static final McpJsonMapper JSON_MAPPER = McpJsonMapper.createDefault(); - -} \ 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 df18b1b8b..000000000 --- a/mcp-spring/mcp-spring-webmvc/pom.xml +++ /dev/null @@ -1,154 +0,0 @@ - - - 4.0.0 - - io.modelcontextprotocol.sdk - mcp-parent - 0.18.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-json-jackson2 - 0.18.0-SNAPSHOT - - - - io.modelcontextprotocol.sdk - mcp - 0.18.0-SNAPSHOT - - - - org.springframework - spring-webmvc - ${springframework.version} - - - - io.modelcontextprotocol.sdk - mcp-test - 0.18.0-SNAPSHOT - test - - - - io.modelcontextprotocol.sdk - mcp-spring-webflux - 0.18.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 6c35de56d..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProvider.java +++ /dev/null @@ -1,568 +0,0 @@ -/* - * Copyright 2024-2024 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.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; - -import io.modelcontextprotocol.common.McpTransportContext; -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; - - /** - * 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}. - * @throws IllegalArgumentException if any parameter is null - */ - private WebMvcSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint, - String sseEndpoint, Duration keepAliveInterval, - McpTransportContextExtractor contextExtractor) { - 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"); - - this.jsonMapper = jsonMapper; - this.baseUrl = baseUrl; - this.messageEndpoint = messageEndpoint; - this.sseEndpoint = sseEndpoint; - this.contextExtractor = contextExtractor; - 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"); - } - - // 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"); - } - - 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; - - /** - * 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; - } - - /** - * 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 ? McpJsonMapper.getDefault() : jsonMapper, - baseUrl, messageEndpoint, sseEndpoint, keepAliveInterval, contextExtractor); - } - - } - -} 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 4223084ff..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStatelessServerTransport.java +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright 2024-2024 the original author or authors. - */ - -package io.modelcontextprotocol.server.transport; - -import io.modelcontextprotocol.common.McpTransportContext; -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; - -/** - * 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; - - private WebMvcStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, - McpTransportContextExtractor contextExtractor) { - Assert.notNull(jsonMapper, "jsonMapper must not be null"); - Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null"); - Assert.notNull(contextExtractor, "contextExtractor must not be null"); - - this.jsonMapper = jsonMapper; - this.mcpEndpoint = mcpEndpoint; - this.contextExtractor = contextExtractor; - 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"); - } - - 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(); - return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(jsonrpcResponse); - } - 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 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; - } - - /** - * 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 ? McpJsonMapper.getDefault() : jsonMapper, - mcpEndpoint, contextExtractor); - } - - } - -} 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 f2a58d4d8..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/main/java/io/modelcontextprotocol/server/transport/WebMvcStreamableServerTransportProvider.java +++ /dev/null @@ -1,690 +0,0 @@ -/* - * Copyright 2024-2024 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.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; - -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; - - /** - * Constructs a new WebMvcStreamableServerTransportProvider 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 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. - * @throws IllegalArgumentException if any parameter is null - */ - private WebMvcStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, - boolean disallowDelete, McpTransportContextExtractor contextExtractor, - Duration keepAliveInterval) { - 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"); - - this.jsonMapper = jsonMapper; - this.mcpEndpoint = mcpEndpoint; - this.disallowDelete = disallowDelete; - this.contextExtractor = contextExtractor; - 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); - } - - @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"); - } - - 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"); - } - - 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"); - } - - 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; - - /** - * 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; - } - - /** - * 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 ? McpJsonMapper.getDefault() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval); - } - - } - -} 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/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 1074e8a35..000000000 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/transport/WebMvcSseServerTransportProviderTests.java +++ /dev/null @@ -1,119 +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.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(McpJsonMapper.getDefault()) - .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 7fc22e5d2..531c0bbc5 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -1,12 +1,12 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 io.modelcontextprotocol.sdk mcp-parent - 0.18.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp-test jar @@ -23,8 +23,8 @@ io.modelcontextprotocol.sdk - mcp - 0.18.0-SNAPSHOT + mcp-core + 1.1.0-SNAPSHOT @@ -33,12 +33,6 @@ ${slf4j-api.version} - - com.fasterxml.jackson.core - jackson-databind - ${jackson.version} - - io.projectreactor reactor-core @@ -97,8 +91,91 @@ ${json-unit-assertj.version} + + + org.springframework + spring-webmvc + ${springframework.version} + test + + + + org.springframework + spring-context + ${springframework.version} + test + + + + org.springframework + spring-test + ${springframework.version} + test + + + + io.projectreactor.netty + reactor-netty-http + test + + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + test + + + + org.apache.tomcat.embed + tomcat-embed-websocket + ${tomcat.version} + test + + + + net.bytebuddy + byte-buddy + ${byte-buddy.version} + test + + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet.version} + test + + + + jackson3 + + true + + + + io.modelcontextprotocol.sdk + mcp-json-jackson3 + 1.1.0-SNAPSHOT + test + + + + + jackson2 + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + 1.1.0-SNAPSHOT + test + + + + + \ No newline at end of file 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 d0b1c46a2..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,12 +45,12 @@ public abstract class AbstractMcpAsyncClientResiliencyTests { private static final Logger logger = LoggerFactory.getLogger(AbstractMcpAsyncClientResiliencyTests.class); static Network network = Network.newNetwork(); - static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image + public static String host = "http://localhost:3001"; + @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + 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())) .withNetwork(network) .withNetworkAliases("everything-server") diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index e1b051204..bee8f4f16 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.client; +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; @@ -52,8 +53,6 @@ import reactor.core.publisher.Sinks; import reactor.test.StepVerifier; -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; - /** * Test suite for the {@link McpAsyncClient} that can be used with different * {@link McpTransport} implementations. @@ -72,7 +71,7 @@ protected Duration getRequestTimeout() { } protected Duration getInitializationTimeout() { - return Duration.ofSeconds(2); + return Duration.ofSeconds(20); } McpAsyncClient client(McpClientTransport transport) { @@ -503,57 +502,64 @@ void testRemoveNonExistentRoot() { @Test void testReadResource() { + AtomicInteger resourceCount = new AtomicInteger(); withClient(createMcpTransport(), client -> { Flux resources = client.initialize() .then(client.listResources(null)) - .flatMapMany(r -> Flux.fromIterable(r.resources())) + .flatMapMany(r -> { + List l = r.resources(); + resourceCount.set(l.size()); + return Flux.fromIterable(l); + }) .flatMap(r -> client.readResource(r)); - StepVerifier.create(resources).recordWith(ArrayList::new).consumeRecordedWith(readResourceResults -> { - - for (ReadResourceResult result : readResourceResults) { - - assertThat(result).isNotNull(); - assertThat(result.contents()).isNotNull().isNotEmpty(); - - // Validate each content item - for (ResourceContents content : result.contents()) { - assertThat(content).isNotNull(); - assertThat(content.uri()).isNotNull().isNotEmpty(); - assertThat(content.mimeType()).isNotNull().isNotEmpty(); - - // Validate content based on its type with more comprehensive - // checks - switch (content.mimeType()) { - case "text/plain" -> { - TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, - content); - assertThat(textContent.text()).isNotNull().isNotEmpty(); - assertThat(textContent.uri()).isNotEmpty(); - } - case "application/octet-stream" -> { - BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, - content); - assertThat(blobContent.blob()).isNotNull().isNotEmpty(); - assertThat(blobContent.uri()).isNotNull().isNotEmpty(); - // Validate base64 encoding format - assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); - } - default -> { - - // Still validate basic properties - if (content instanceof TextResourceContents textContent) { - assertThat(textContent.text()).isNotNull(); + StepVerifier.create(resources) + .recordWith(ArrayList::new) + .thenConsumeWhile(res -> true) + .consumeRecordedWith(readResourceResults -> { + assertThat(readResourceResults.size()).isEqualTo(resourceCount.get()); + for (ReadResourceResult result : readResourceResults) { + + assertThat(result).isNotNull(); + assertThat(result.contents()).isNotNull().isNotEmpty(); + + // Validate each content item + for (ResourceContents content : result.contents()) { + assertThat(content).isNotNull(); + assertThat(content.uri()).isNotNull().isNotEmpty(); + assertThat(content.mimeType()).isNotNull().isNotEmpty(); + + // Validate content based on its type with more comprehensive + // checks + switch (content.mimeType()) { + case "text/plain" -> { + TextResourceContents textContent = assertInstanceOf(TextResourceContents.class, + content); + assertThat(textContent.text()).isNotNull().isNotEmpty(); + assertThat(textContent.uri()).isNotEmpty(); } - else if (content instanceof BlobResourceContents blobContent) { - assertThat(blobContent.blob()).isNotNull(); + case "application/octet-stream" -> { + BlobResourceContents blobContent = assertInstanceOf(BlobResourceContents.class, + content); + assertThat(blobContent.blob()).isNotNull().isNotEmpty(); + assertThat(blobContent.uri()).isNotNull().isNotEmpty(); + // Validate base64 encoding format + assertThat(blobContent.blob()).matches("^[A-Za-z0-9+/]*={0,2}$"); + } + default -> { + + // Still validate basic properties + if (content instanceof TextResourceContents textContent) { + assertThat(textContent.text()).isNotNull(); + } + else if (content instanceof BlobResourceContents blobContent) { + assertThat(blobContent.blob()).isNotNull(); + } } } } } - } - }) - .expectNextCount(10) // Expect 10 elements + }) .verifyComplete(); }); } @@ -673,7 +679,7 @@ void testInitializeWithElicitationCapability() { @Test void testInitializeWithAllCapabilities() { var capabilities = ClientCapabilities.builder() - .experimental(Map.of("feature", "test")) + .experimental(Map.of("feature", Map.of("featureFlag", true))) .roots(true) .sampling() .build(); @@ -693,7 +699,6 @@ void testInitializeWithAllCapabilities() { assertThat(result.capabilities()).isNotNull(); }).verifyComplete()); } - // --------------------------------------- // Logging Tests // --------------------------------------- @@ -773,7 +778,7 @@ void testSampling() { if (!(content instanceof McpSchema.TextContent text)) return; - assertThat(text.text()).endsWith(response); // Prefixed + assertThat(text.text()).contains(response); }); // Verify sampling request parameters received in our callback diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java index 21e0c1492..26d60568a 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java @@ -624,7 +624,7 @@ void testSampling() { if (!(content instanceof McpSchema.TextContent text)) return; - assertThat(text.text()).endsWith(response); // Prefixed + assertThat(text.text()).contains(response); }); // Verify sampling request parameters received in our callback 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/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java b/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java index 723965519..a72fc1db8 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/util/McpJsonMapperUtils.java @@ -1,5 +1,6 @@ package io.modelcontextprotocol.util; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; public final class McpJsonMapperUtils { @@ -7,6 +8,6 @@ public final class McpJsonMapperUtils { private McpJsonMapperUtils() { } - public static final McpJsonMapper JSON_MAPPER = McpJsonMapper.getDefault(); + public static final McpJsonMapper JSON_MAPPER = McpJsonDefaults.getMapper(); } \ No newline at end of file diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java similarity index 74% rename from mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java rename to mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java index cd8458311..4e74dac3e 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/MockMcpTransport.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpClientTransport.java @@ -9,40 +9,47 @@ import java.util.function.BiConsumer; import java.util.function.Function; -import io.modelcontextprotocol.json.McpJsonMapper; +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 io.modelcontextprotocol.spec.McpServerTransport; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; /** - * A mock implementation of the {@link McpClientTransport} and {@link McpServerTransport} - * interfaces. - * - * @deprecated not used. to be removed in the future. + * A mock implementation of the {@link McpClientTransport} interfaces. */ -@Deprecated -public class MockMcpTransport implements McpClientTransport, McpServerTransport { +public class MockMcpClientTransport implements McpClientTransport { private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer(); private final List sent = new ArrayList<>(); - private final BiConsumer interceptor; + private final BiConsumer interceptor; - public MockMcpTransport() { + private String protocolVersion = ProtocolVersions.MCP_2025_11_25; + + public MockMcpClientTransport() { this((t, msg) -> { }); } - public MockMcpTransport(BiConsumer interceptor) { + public MockMcpClientTransport(BiConsumer interceptor) { this.interceptor = interceptor; } + public MockMcpClientTransport withProtocolVersion(String protocolVersion) { + return this; + } + + @Override + public List protocolVersions() { + return List.of(protocolVersion); + } + public void simulateIncomingMessage(McpSchema.JSONRPCMessage message) { if (inbound.tryEmitNext(message).isFailure()) { throw new RuntimeException("Failed to process incoming message " + message); @@ -94,7 +101,7 @@ public Mono closeGracefully() { @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return McpJsonMapper.getDefault().convertValue(data, typeRef); + return McpJsonDefaults.getMapper().convertValue(data, typeRef); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java similarity index 94% rename from mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java rename to mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java index f3d6b77a7..fac26596a 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java @@ -8,7 +8,7 @@ import java.util.List; import java.util.function.BiConsumer; -import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification; @@ -68,7 +68,7 @@ public Mono closeGracefully() { @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return McpJsonMapper.getDefault().convertValue(data, typeRef); + return McpJsonDefaults.getMapper().convertValue(data, typeRef); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java rename to mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientResiliencyTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientResiliencyTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientResiliencyTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientResiliencyTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java similarity index 88% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java index c4157bc37..a29ca16db 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpAsyncClientTests.java @@ -17,10 +17,9 @@ public class HttpClientStreamableHttpAsyncClientTests extends AbstractMcpAsyncCl private static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + 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)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java similarity index 93% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java index d59ae35b4..ee5e5de05 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpClientStreamableHttpSyncClientTests.java @@ -30,10 +30,9 @@ public class HttpClientStreamableHttpSyncClientTests extends AbstractMcpSyncClie static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + 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)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java similarity index 96% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java index 30e7fe913..e2037f415 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientLostConnectionTests.java @@ -36,10 +36,9 @@ public class HttpSseMcpAsyncClientLostConnectionTests { static Network network = Network.newNetwork(); static String host = "http://localhost:3001"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + 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())) .withNetwork(network) .withNetworkAliases("everything-server") diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java similarity index 89% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java index f467289ff..91a8b6c82 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpAsyncClientTests.java @@ -23,10 +23,9 @@ class HttpSseMcpAsyncClientTests extends AbstractMcpAsyncClientTests { private static String host = "http://localhost:3004"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + 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)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java similarity index 94% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java index 483d38669..d903b3b3c 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/HttpSseMcpSyncClientTests.java @@ -36,10 +36,9 @@ class HttpSseMcpSyncClientTests extends AbstractMcpSyncClientTests { static String host = "http://localhost:3003"; - // Uses the https://github.com/tzolov/mcp-everything-server-docker-image @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + 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)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java similarity index 98% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java index 612a65898..47a229afd 100644 --- a/mcp-core/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-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java similarity index 96% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientProtocolVersionTests.java index a94b9b6a7..03f64aa64 100644 --- a/mcp-core/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-core/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java similarity index 73% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java index 63ec015fe..547ccc52f 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java @@ -10,10 +10,12 @@ private ServerParameterUtils() { public static ServerParameters createServerParameters() { if (System.getProperty("os.name").toLowerCase().contains("win")) { return ServerParameters.builder("cmd.exe") - .args("/c", "npx.cmd", "-y", "@modelcontextprotocol/server-everything", "stdio") + .args("/c", "npx.cmd", "-y", "@modelcontextprotocol/server-everything@2025.12.18", "stdio") .build(); } - return ServerParameters.builder("npx").args("-y", "@modelcontextprotocol/server-everything", "stdio").build(); + return ServerParameters.builder("npx") + .args("-y", "@modelcontextprotocol/server-everything@2025.12.18", "stdio") + .build(); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java similarity index 98% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java index b1e567989..08e5ea61a 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java @@ -67,7 +67,7 @@ void customErrorHandlerShouldReceiveErrors() throws InterruptedException { } protected Duration getInitializationTimeout() { - return Duration.ofSeconds(10); + return Duration.ofSeconds(25); } @Override diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java similarity index 99% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java index c5c365798..a24805a30 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java @@ -58,8 +58,8 @@ class HttpClientSseClientTransportTests { static String host = "http://localhost:3001"; @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js sse") + 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)); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportEmptyJsonResponseTest.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java similarity index 89% rename from mcp-core/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java rename to mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportTest.java index f9536b690..f88736a5d 100644 --- a/mcp-core/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; @@ -40,8 +41,8 @@ class HttpClientStreamableHttpTransportTest { .create(Map.of("test-transport-context-key", "some-value")); @SuppressWarnings("resource") - static GenericContainer container = new GenericContainer<>("docker.io/tzolov/mcp-everything-server:v3") - .withCommand("node dist/index.js streamableHttp") + 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)); @@ -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, @@ -90,7 +91,7 @@ void testRequestCustomizer() throws URISyntaxException { // Verify the customizer was called verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq( - "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), eq(context)); }); } @@ -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, @@ -120,7 +121,7 @@ void testAsyncRequestCustomizer() throws URISyntaxException { // Verify the customizer was called verify(mockRequestCustomizer, atLeastOnce()).customize(any(), eq("POST"), eq(uri), eq( - "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), + "{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"test-id\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"roots\":{\"listChanged\":true}},\"clientInfo\":{\"name\":\"MCP Client\",\"version\":\"0.3.1\"}}}"), eq(context)); }); } @@ -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-core/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java similarity index 95% rename from mcp-core/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/common/AsyncServerMcpTransportContextIntegrationTests.java index 8b2dea462..ce381436d 100644 --- a/mcp-core/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-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java similarity index 90% rename from mcp-core/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java index 8efb6a960..29eef1410 100644 --- a/mcp-core/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 @@ -77,14 +79,14 @@ void usesLatestVersion() { .hasSize(3) .map(McpTestRequestRecordingServletFilter.Call::headers) .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", - ProtocolVersions.MCP_2025_06_18)); + 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_06_18); + .isEqualTo(ProtocolVersions.MCP_2025_11_25); mcpServer.close(); } @@ -93,7 +95,7 @@ void usesServerSupportedVersion() { startTomcat(); var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) - .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_06_18, "2263-03-18")) + .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_11_25, "2263-03-18")) .build(); var client = McpClient.sync(transport).build(); @@ -108,14 +110,14 @@ void usesServerSupportedVersion() { .hasSize(2) .map(McpTestRequestRecordingServletFilter.Call::headers) .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", - ProtocolVersions.MCP_2025_06_18)); + 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_06_18); + .isEqualTo(ProtocolVersions.MCP_2025_11_25); mcpServer.close(); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java similarity index 95% rename from mcp-core/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/common/SyncServerMcpTransportContextIntegrationTests.java index cc8f4c4be..563e2167d 100644 --- a/mcp-core/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-core/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java similarity index 97% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java index d2b9d14d0..5841c13da 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.stream.Stream; +import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; import io.modelcontextprotocol.common.McpTransportContext; diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableAsyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableAsyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableAsyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableAsyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java similarity index 97% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java index 81423e0c5..5b934e4e9 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.stream.Stream; +import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.common.McpTransportContext; diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableSyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableSyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableSyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableSyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java similarity index 94% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java index cdd2bacb7..d9f899020 100644 --- a/mcp-core/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-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceTemplateManagementTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/ServletSseMcpAsyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/ServletSseMcpSyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/StdioMcpAsyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/StdioMcpSyncServerTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/transport/HttpServletSseServerCustomContextPathTests.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java new file mode 100644 index 000000000..10bb30568 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java @@ -0,0 +1,339 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +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.server.McpServer; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.servlet.http.HttpServlet; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.LifecycleState; +import org.apache.catalina.startup.Tomcat; +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 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") +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 Transport transport; + + private static Tomcat tomcat; + + private static String baseUrl; + + @BeforeParameterizedClassInvocation + static void createTransportAndStartTomcat(Transport transport) { + var port = TomcatTestUtil.findAvailablePort(); + baseUrl = "http://localhost:" + port; + startTomcat(transport.servlet(), port); + } + + @AfterAll + static void afterAll() { + stopTomcat(); + } + + private McpSyncClient mcpClient; + + private final TestRequestCustomizer requestCustomizer = new TestRequestCustomizer(); + + @BeforeEach + void setUp() { + requestCustomizer.reset(); + mcpClient = transport.createMcpClient(baseUrl, requestCustomizer); + } + + @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(jakarta.servlet.Servlet servlet, int port) { + tomcat = TomcatTestUtil.createTomcatServer("", port, servlet); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + private static void stopTomcat() { + if (tomcat != null) { + try { + tomcat.stop(); + 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", 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, TestRequestCustomizer requestCustomizer); + + HttpServlet servlet(); + + } + + /** + * SSE-based transport. + */ + static class Sse implements Transport { + + private final HttpServletSseServerTransportProvider transport; + + public Sse() { + transport = HttpServletSseServerTransportProvider.builder() + .messageEndpoint("/mcp/message") + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) + .build(); + McpServer.sync(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + @Override + public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { + var transport = HttpClientSseClientTransport.builder(baseUrl) + .httpRequestCustomizer(requestCustomizer) + .jsonMapper(McpJsonDefaults.getMapper()) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Override + public HttpServlet servlet() { + return transport; + } + + } + + static class StreamableHttp implements Transport { + + private final HttpServletStreamableServerTransportProvider transport; + + public StreamableHttp() { + transport = HttpServletStreamableServerTransportProvider.builder() + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) + .build(); + McpServer.sync(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + @Override + public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { + var transport = HttpClientStreamableHttpTransport.builder(baseUrl) + .httpRequestCustomizer(requestCustomizer) + .jsonMapper(McpJsonDefaults.getMapper()) + .openConnectionOnStartup(true) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Override + public HttpServlet servlet() { + return transport; + } + + } + + static class Stateless implements Transport { + + private final HttpServletStatelessServerTransport transport; + + public Stateless() { + transport = HttpServletStatelessServerTransport.builder() + .securityValidator(DefaultServerTransportSecurityValidator.builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build()) + .build(); + McpServer.sync(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .build(); + } + + @Override + public McpSyncClient createMcpClient(String baseUrl, TestRequestCustomizer requestCustomizer) { + var transport = HttpClientStreamableHttpTransport.builder(baseUrl) + .httpRequestCustomizer(requestCustomizer) + .jsonMapper(McpJsonDefaults.getMapper()) + .openConnectionOnStartup(true) + .build(); + return McpClient.sync(transport).initializationTimeout(Duration.ofMillis(500)).build(); + } + + @Override + public HttpServlet servlet() { + return transport; + } + + } + + 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) { + // HttpClient normally sets Host automatically, but we can override it + 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-core/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java similarity index 92% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java index 6a70af33d..5390cc4c2 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java @@ -14,6 +14,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerSession; @@ -25,7 +26,6 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -37,7 +37,6 @@ * * @author Christian Tzolov */ -@Disabled class StdioServerTransportProviderTests { private final PrintStream originalOut = System.out; @@ -71,7 +70,8 @@ void setUp() { when(mockSession.closeGracefully()).thenReturn(Mono.empty()); when(mockSession.sendNotification(any(), any())).thenReturn(Mono.empty()); - transportProvider = new StdioServerTransportProvider(JSON_MAPPER, System.in, testOutPrintStream); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), System.in, + testOutPrintStream); } @AfterEach @@ -101,7 +101,7 @@ void shouldHandleIncomingMessages() throws Exception { String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{},\"id\":1}\n"; InputStream stream = new ByteArrayInputStream(jsonMessage.getBytes(StandardCharsets.UTF_8)); - transportProvider = new StdioServerTransportProvider(JSON_MAPPER, stream, System.out); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), stream, System.out); // Set up a real session to capture the message AtomicReference capturedMessage = new AtomicReference<>(); CountDownLatch messageLatch = new CountDownLatch(1); @@ -181,11 +181,11 @@ void shouldHandleMultipleCloseGracefullyCalls() { @Test void shouldHandleNotificationBeforeSessionFactoryIsSet() { - transportProvider = new StdioServerTransportProvider(JSON_MAPPER); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper()); // 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); }); } @@ -196,7 +196,7 @@ void shouldHandleInvalidJsonMessage() throws Exception { String jsonMessage = "{invalid json}\n"; InputStream stream = new ByteArrayInputStream(jsonMessage.getBytes(StandardCharsets.UTF_8)); - transportProvider = new StdioServerTransportProvider(JSON_MAPPER, stream, testOutPrintStream); + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), stream, testOutPrintStream); // Set up a session factory transportProvider.setSessionFactory(sessionFactory); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java similarity index 100% rename from mcp-core/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java rename to mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java similarity index 89% rename from mcp-core/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java rename to mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java index 55f71fea4..195b6ec6d 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteCompletionSerializationTest.java @@ -1,5 +1,6 @@ package io.modelcontextprotocol.spec; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.json.McpJsonMapper; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -10,7 +11,7 @@ class CompleteCompletionSerializationTest { @Test void codeCompletionSerialization() throws IOException { - McpJsonMapper jsonMapper = McpJsonMapper.getDefault(); + McpJsonMapper jsonMapper = McpJsonDefaults.getMapper(); McpSchema.CompleteResult.CompleteCompletion codeComplete = new McpSchema.CompleteResult.CompleteCompletion( Collections.emptyList(), 0, false); String json = jsonMapper.writeValueAsString(codeComplete); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java similarity index 97% rename from mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java rename to mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 6b0004cb9..942e0a6e2 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -10,17 +10,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.io.IOException; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; 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 com.fasterxml.jackson.databind.exc.InvalidTypeIdException; - -import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import net.javacrumbs.jsonunit.core.Option; /** @@ -55,18 +55,24 @@ void testTextContentDeserialization() throws Exception { } @Test - void testContentDeserializationWrongType() throws Exception { - + void testContentDeserializationWrongType() { assertThatThrownBy(() -> JSON_MAPPER.readValue(""" - {"type":"WRONG","text":"XXX"}""", McpSchema.TextContent.class)) - .isInstanceOf(InvalidTypeIdException.class) + {"type":"WRONG","text":"XXX"}""", McpSchema.TextContent.class)).isInstanceOf(IOException.class) + // Jackson 2 throws the InvalidTypeException directly, but Jackson 3 wraps it. + // Try to unwrap in case it's Jackson 3. + .extracting(throwable -> throwable.getCause() != null ? throwable.getCause() : throwable) + .asInstanceOf(InstanceOfAssertFactories.THROWABLE) .hasMessageContaining( - "Could not resolve type id 'WRONG' as a subtype of `io.modelcontextprotocol.spec.McpSchema$TextContent`: known type ids = [audio, image, resource, resource_link, text]"); + "Could not resolve type id 'WRONG' as a subtype of `io.modelcontextprotocol.spec.McpSchema$TextContent`: known type ids = [audio, image, resource, resource_link, text]") + .extracting(Object::getClass) + .extracting(Class::getSimpleName) + // Class name is the same for both Jackson 2 and 3, only the package differs. + .isEqualTo("InvalidTypeIdException"); } @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) @@ -154,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) @@ -185,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) @@ -360,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) @@ -445,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"); @@ -1268,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) @@ -1289,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(); @@ -1320,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 0e0ed1288..937974228 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.18.0-SNAPSHOT + 1.1.0-SNAPSHOT mcp jar @@ -24,14 +24,14 @@ io.modelcontextprotocol.sdk - mcp-json-jackson2 - 0.18.0-SNAPSHOT + mcp-json-jackson3 + 1.1.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-core - 0.18.0-SNAPSHOT + 1.1.0-SNAPSHOT diff --git a/migration-0.8.0.md b/migration-0.8.0.md deleted file mode 100644 index 3ba29a10b..000000000 --- a/migration-0.8.0.md +++ /dev/null @@ -1,328 +0,0 @@ -# MCP Java SDK Migration Guide: 0.7.0 to 0.8.0 - -This document outlines the breaking changes and provides guidance on how to migrate your code from version 0.7.0 to 0.8.0. - -The 0.8.0 refactoring introduces a session-based architecture for server-side MCP implementations. -It improves the SDK's ability to handle multiple concurrent client connections and provides an API better aligned with the MCP specification. -The main changes include: - -1. Introduction of a session-based architecture -2. New transport provider abstraction -3. Exchange objects for client interaction -4. Renamed and reorganized interfaces -5. Updated handler signatures - -## Breaking Changes - -### 1. Interface Renaming - -Several interfaces have been renamed to better reflect their roles: - -| 0.7.0 (Old) | 0.8.0 (New) | -|-------------|-------------| -| `ClientMcpTransport` | `McpClientTransport` | -| `ServerMcpTransport` | `McpServerTransport` | -| `DefaultMcpSession` | `McpClientSession`, `McpServerSession` | - -### 2. New Server Transport Architecture - -The most significant change is the introduction of the `McpServerTransportProvider` interface, which replaces direct usage of `ServerMcpTransport` when creating servers. This new pattern separates the concerns of: - -1. **Transport Provider**: Manages connections with clients and creates individual transports for each connection -2. **Server Transport**: Handles communication with a specific client connection - -| 0.7.0 (Old) | 0.8.0 (New) | -|-------------|-------------| -| `ServerMcpTransport` | `McpServerTransportProvider` + `McpServerTransport` | -| Direct transport usage | Session-based transport usage | - -#### Before (0.7.0): - -```java -// Create a transport -ServerMcpTransport transport = new WebFluxSseServerTransport(objectMapper, "/mcp/message"); - -// Create a server with the transport -McpServer.sync(transport) - .serverInfo("my-server", "1.0.0") - .build(); -``` - -#### After (0.8.0): - -```java -// Create a transport provider -McpServerTransportProvider transportProvider = new WebFluxSseServerTransportProvider(objectMapper, "/mcp/message"); - -// Create a server with the transport provider -McpServer.sync(transportProvider) - .serverInfo("my-server", "1.0.0") - .build(); -``` - -### 3. Handler Method Signature Changes - -Tool, resource, and prompt handlers now receive an additional `exchange` parameter that provides access to client capabilities and methods to interact with the client: - -| 0.7.0 (Old) | 0.8.0 (New) | -|-------------|-------------| -| `(args) -> result` | `(exchange, args) -> result` | - -The exchange objects (`McpAsyncServerExchange` and `McpSyncServerExchange`) provide context for the current session and access to session-specific operations. - -#### Before (0.7.0): - -```java -// Tool handler -.tool(calculatorTool, args -> new CallToolResult("Result: " + calculate(args))) - -// Resource handler -.resource(fileResource, req -> new ReadResourceResult(readFile(req))) - -// Prompt handler -.prompt(analysisPrompt, req -> new GetPromptResult("Analysis prompt")) -``` - -#### After (0.8.0): - -```java -// Tool handler -.tool(calculatorTool, (exchange, args) -> new CallToolResult("Result: " + calculate(args))) - -// Resource handler -.resource(fileResource, (exchange, req) -> new ReadResourceResult(readFile(req))) - -// Prompt handler -.prompt(analysisPrompt, (exchange, req) -> new GetPromptResult("Analysis prompt")) -``` - -### 4. Registration vs. Specification - -The naming convention for handlers has changed from "Registration" to "Specification": - -| 0.7.0 (Old) | 0.8.0 (New) | -|-------------|-------------| -| `AsyncToolRegistration` | `AsyncToolSpecification` | -| `SyncToolRegistration` | `SyncToolSpecification` | -| `AsyncResourceRegistration` | `AsyncResourceSpecification` | -| `SyncResourceRegistration` | `SyncResourceSpecification` | -| `AsyncPromptRegistration` | `AsyncPromptSpecification` | -| `SyncPromptRegistration` | `SyncPromptSpecification` | - -### 5. Roots Change Handler Updates - -The roots change handlers now receive an exchange parameter: - -#### Before (0.7.0): - -```java -.rootsChangeConsumers(List.of( - roots -> { - // Process roots - } -)) -``` - -#### After (0.8.0): - -```java -.rootsChangeHandlers(List.of( - (exchange, roots) -> { - // Process roots with access to exchange - } -)) -``` - -### 6. Server Creation Method Changes - -The `McpServer` factory methods now accept `McpServerTransportProvider` instead of `ServerMcpTransport`: - -| 0.7.0 (Old) | 0.8.0 (New) | -|-------------|-------------| -| `McpServer.async(ServerMcpTransport)` | `McpServer.async(McpServerTransportProvider)` | -| `McpServer.sync(ServerMcpTransport)` | `McpServer.sync(McpServerTransportProvider)` | - -The method names for creating servers have been updated: - -Root change handlers now receive an exchange object: - -| 0.7.0 (Old) | 0.8.0 (New) | -|-------------|-------------| -| `rootsChangeConsumers(List>>)` | `rootsChangeHandlers(List>>)` | -| `rootsChangeConsumer(Consumer>)` | `rootsChangeHandler(BiConsumer>)` | - -### 7. Direct Server Methods Moving to Exchange - -Several methods that were previously available directly on the server are now accessed through the exchange object: - -| 0.7.0 (Old) | 0.8.0 (New) | -|-------------|-------------| -| `server.listRoots()` | `exchange.listRoots()` | -| `server.createMessage()` | `exchange.createMessage()` | -| `server.getClientCapabilities()` | `exchange.getClientCapabilities()` | -| `server.getClientInfo()` | `exchange.getClientInfo()` | - -The direct methods are deprecated and will be removed in 0.9.0: - -- `McpSyncServer.listRoots()` -- `McpSyncServer.getClientCapabilities()` -- `McpSyncServer.getClientInfo()` -- `McpSyncServer.createMessage()` -- `McpAsyncServer.listRoots()` -- `McpAsyncServer.getClientCapabilities()` -- `McpAsyncServer.getClientInfo()` -- `McpAsyncServer.createMessage()` - -## Deprecation Notices - -The following components are deprecated in 0.8.0 and will be removed in 0.9.0: - -- `ClientMcpTransport` interface (use `McpClientTransport` instead) -- `ServerMcpTransport` interface (use `McpServerTransport` instead) -- `DefaultMcpSession` class (use `McpClientSession` instead) -- `WebFluxSseServerTransport` class (use `WebFluxSseServerTransportProvider` instead) -- `WebMvcSseServerTransport` class (use `WebMvcSseServerTransportProvider` instead) -- `StdioServerTransport` class (use `StdioServerTransportProvider` instead) -- All `*Registration` classes (use corresponding `*Specification` classes instead) -- Direct server methods for client interaction (use exchange object instead) - -## Migration Examples - -### Example 1: Creating a Server - -#### Before (0.7.0): - -```java -// Create a transport -ServerMcpTransport transport = new WebFluxSseServerTransport(objectMapper, "/mcp/message"); - -// Create a server with the transport -var server = McpServer.sync(transport) - .serverInfo("my-server", "1.0.0") - .tool(calculatorTool, args -> new CallToolResult("Result: " + calculate(args))) - .rootsChangeConsumers(List.of( - roots -> System.out.println("Roots changed: " + roots) - )) - .build(); - -// Get client capabilities directly from server -ClientCapabilities capabilities = server.getClientCapabilities(); -``` - -#### After (0.8.0): - -```java -// Create a transport provider -McpServerTransportProvider transportProvider = new WebFluxSseServerTransportProvider(objectMapper, "/mcp/message"); - -// Create a server with the transport provider -var server = McpServer.sync(transportProvider) - .serverInfo("my-server", "1.0.0") - .tool(calculatorTool, (exchange, args) -> { - // Get client capabilities from exchange - ClientCapabilities capabilities = exchange.getClientCapabilities(); - return new CallToolResult("Result: " + calculate(args)); - }) - .rootsChangeHandlers(List.of( - (exchange, roots) -> System.out.println("Roots changed: " + roots) - )) - .build(); -``` - -### Example 2: Implementing a Tool with Client Interaction - -#### Before (0.7.0): - -```java -McpServerFeatures.SyncToolRegistration tool = new McpServerFeatures.SyncToolRegistration( - new Tool("weather", "Get weather information", schema), - args -> { - String location = (String) args.get("location"); - // Cannot interact with client from here - return new CallToolResult("Weather for " + location + ": Sunny"); - } -); - -var server = McpServer.sync(transport) - .tools(tool) - .build(); - -// Separate call to create a message -CreateMessageResult result = server.createMessage(new CreateMessageRequest(...)); -``` - -#### After (0.8.0): - -```java -McpServerFeatures.SyncToolSpecification tool = new McpServerFeatures.SyncToolSpecification( - new Tool("weather", "Get weather information", schema), - (exchange, args) -> { - String location = (String) args.get("location"); - - // Can interact with client directly from the tool handler - CreateMessageResult result = exchange.createMessage(new CreateMessageRequest(...)); - - return new CallToolResult("Weather for " + location + ": " + result.content()); - } -); - -var server = McpServer.sync(transportProvider) - .tools(tool) - .build(); -``` - -### Example 3: Converting Existing Registration Classes - -If you have custom implementations of the registration classes, you can convert them to the new specification classes: - -#### Before (0.7.0): - -```java -McpServerFeatures.AsyncToolRegistration toolReg = new McpServerFeatures.AsyncToolRegistration( - tool, - args -> Mono.just(new CallToolResult("Result")) -); - -McpServerFeatures.AsyncResourceRegistration resourceReg = new McpServerFeatures.AsyncResourceRegistration( - resource, - req -> Mono.just(new ReadResourceResult(List.of())) -); -``` - -#### After (0.8.0): - -```java -// Option 1: Create new specification directly -McpServerFeatures.AsyncToolSpecification toolSpec = new McpServerFeatures.AsyncToolSpecification( - tool, - (exchange, args) -> Mono.just(new CallToolResult("Result")) -); - -// Option 2: Convert from existing registration (during transition) -McpServerFeatures.AsyncToolRegistration oldToolReg = /* existing registration */; -McpServerFeatures.AsyncToolSpecification toolSpec = oldToolReg.toSpecification(); - -// Similarly for resources -McpServerFeatures.AsyncResourceSpecification resourceSpec = new McpServerFeatures.AsyncResourceSpecification( - resource, - (exchange, req) -> Mono.just(new ReadResourceResult(List.of())) -); -``` - -## Architecture Changes - -### Session-Based Architecture - -In 0.8.0, the MCP Java SDK introduces a session-based architecture where each client connection has its own session. This allows for better isolation between clients and more efficient resource management. - -The `McpServerTransportProvider` is responsible for creating `McpServerTransport` instances for each session, and the `McpServerSession` manages the communication with a specific client. - -### Exchange Objects - -The new exchange objects (`McpAsyncServerExchange` and `McpSyncServerExchange`) provide access to client-specific information and methods. They are passed to handler functions as the first parameter, allowing handlers to interact with the specific client that made the request. - -## Conclusion - -The changes in version 0.8.0 represent a significant architectural improvement to the MCP Java SDK. While they require some code changes, the new design provides a more flexible and maintainable foundation for building MCP applications. - -For assistance with migration or to report issues, please open an issue on the GitHub repository. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..3e27c3fb5 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,97 @@ +site_name: MCP Java SDK +site_url: https://modelcontextprotocol.github.io/java-sdk/ +site_description: Java SDK for the Model Context Protocol - standardized integration between AI models and tools +repo_url: https://github.com/modelcontextprotocol/java-sdk +repo_name: modelcontextprotocol/java-sdk +edit_uri: edit/main/docs/ + +theme: + name: material + favicon: images/favicon.svg + logo: images/logo-light.svg + palette: + - scheme: default + primary: blue grey + accent: blue grey + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: blue grey + accent: blue grey + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.instant + - navigation.instant.progress + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.top + - navigation.path + - navigation.indexes + - toc.follow + - search.suggest + - search.highlight + - content.code.copy + - content.code.annotate + - content.tabs.link + +nav: + - Documentation: + - Overview: overview.md + - Quickstart: quickstart.md + - MCP Components: + - MCP Client: client.md + - MCP Server: server.md + - Contributing: + - Contributing Guide: contribute.md + - Documentation: development.md + - API Reference: https://javadoc.io/doc/io.modelcontextprotocol.sdk/mcp-core/latest + - News: + - blog/index.md + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.mark + - pymdownx.critic + - pymdownx.caret + - pymdownx.keys + - pymdownx.tilde + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - attr_list + - md_in_html + - tables + - toc: + permalink: true + +extra: + version: + provider: mike + default: + - latest-snapshot + - latest + social: + - icon: fontawesome/brands/github + link: https://github.com/modelcontextprotocol/java-sdk + generator: false + +plugins: + - search + - blog diff --git a/pom.xml b/pom.xml index f8bc3a9c2..049536e0d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 0.18.0-SNAPSHOT + 1.1.0-SNAPSHOT pom https://github.com/modelcontextprotocol/java-sdk @@ -60,7 +60,7 @@ 3.27.6 - 5.10.2 + 6.0.2 5.20.0 1.21.4 1.17.8 @@ -68,7 +68,9 @@ 2.0.16 1.5.15 - 2.19.2 + 2.20 + 2.20.1 + 3.0.3 6.2.1 @@ -90,13 +92,13 @@ 1.0.0-alpha.4 0.0.4 1.6.2 - 5.10.5 11.0.2 6.1.0 4.2.0 7.1.0 4.1.0 - 2.0.0 + 2.0.0 + 3.0.0 @@ -105,10 +107,9 @@ mcp mcp-core mcp-json-jackson2 - mcp-json - mcp-spring/mcp-spring-webflux - mcp-spring/mcp-spring-webmvc + mcp-json-jackson3 mcp-test + conformance-tests @@ -319,6 +320,9 @@ true central + + mcp-parent,conformance-tests,client-jdk-http-client,client-spring-http-client,server-servlet + true