diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c79351..f055b61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: {} + workflow_dispatch: {} jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b72445..8153de2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,15 @@ Versioning follows [Semantic Versioning](https://semver.org/); see | Line | Latest | Spec revision | Status | |--------|--------|---------------|--------| -| 2.0.x | [2.0.0](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.0) (2026-06-11) | 2025-11-25 | Active development | -| 1.1.x | [1.1.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.3) (2026-05-21) | 2025-06-18 | Security patches only | -| 0.18.x | [0.18.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.3) (2026-06-09) | 2025-06-18 | Security patches only | +| 2.0.x | [2.0.1](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.1) (2026-08-19) | 2025-11-25 | Active development | +| 1.1.x | [1.1.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.4) (2026-08-19) | 2025-06-18 | Security patches only | +| 0.18.x | [0.18.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.4) (2026-08-19) | 2025-06-18 | Security patches only | + +## 2.0.1 — 2026-08-19 + +- Bound STDIO and HTTP client/server reads to a configurable maximum size + +Full notes: [v2.0.1 release](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.1) ## 2.0.0 — 2026-06-11 @@ -42,6 +48,8 @@ Full notes: [v2.0.0 release](https://github.com/modelcontextprotocol/java-sdk/re ## 1.x +- [1.1.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.4) — 2026-08-19: + bound HTTP client/server reads (backport) - [1.1.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.3) / [1.0.2](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.0.2) — 2026-05-21: SSE client transport message-endpoint validation (backports) @@ -54,6 +62,8 @@ Full notes: [v2.0.0 release](https://github.com/modelcontextprotocol/java-sdk/re ## 0.x +- [0.18.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.4) — 2026-08-19: + bound HTTP client/server reads (backport) - [0.18.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.3) — 2026-06-09: security fix for GHSA-hv2w-8mjj-jw22 - [0.18.0](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.0) – diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1adc091..a7bf123 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,12 @@ propose an enhancement. Bug reports should have a reproducer in the form of a co sample or a repository attached that the maintainers or contributors can work with to address the problem. +## AI agents + +**We accept contributions created with the help of AI coding agents, but they must be carefully reviewed by a human who remains accountable for the quality of the contribution.** +These can be issues or pull requests. For issues, please ensure you describe your particular use-case, and not general considerations found by an AI agent. +Contributions submitted by GitHub accounts controlled by autonomous AI bots are forbidden. + ## Making Changes 1. Create a new branch: @@ -63,15 +69,25 @@ git checkout -b feature/your-feature-name ## Submitting Changes +Please don't submit pull requests: + +- With GitHub accounts managed by autonomous AI bots +- For already assigned issues (as the assignee is or plans to work on it) + +When 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 +3. Maintainers will triage issues and act on them. There is no such guarantee on pull requests. + PRs opened without being discussed in an issue MAY be ignored, and maintainers reserve the right + to close these PRs without justification. +4. Push your changes to your fork. +5. Submit a pull request to the main repository. +6. Follow the pull request template. +7. Wait for review. +8. 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. diff --git a/ROADMAP.md b/ROADMAP.md index e6eccde..b71a1b5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ The SDK tracks implementation of MCP spec components via GitHub Projects, with a ### 2.x — Stable Line (2025-11-25 spec) -The current stable release line is **2.x** ([2.0.0](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.0), June 2026), implementing the [2025-11-25 MCP specification revision](https://modelcontextprotocol.io/specification/2025-11-25), including: +The current stable release line is **2.x** (latest: [2.0.1](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.1), August 2026), implementing the [2025-11-25 MCP specification revision](https://modelcontextprotocol.io/specification/2025-11-25), including: - **Spec-accurate schema**: enforced required fields with lenient wire deserialization, and a JSON compatibility foundation for forward/backward wire compatibility - **Enhanced schemas**: JSON Schema 2020-12 validation of tool inputs and embedded schema documents (SEP-1613) diff --git a/VERSIONING.md b/VERSIONING.md index d8efa60..83f15d7 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -45,9 +45,9 @@ The following are **not** considered breaking: | Line | Latest | Spec revision | Status | |--------|--------|---------------|--------| -| 2.0.x | [2.0.0](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.0) (2026-06-11) | 2025-11-25 | Active development | -| 1.1.x | [1.1.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.3) (2026-05-21) | 2025-06-18 | Security patches only | -| 0.18.x | [0.18.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.3) (2026-06-09) | 2025-06-18 | Security patches only | +| 2.0.x | [2.0.1](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.1) (2026-08-19) | 2025-11-25 | Active development | +| 1.1.x | [1.1.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.4) (2026-08-19) | 2025-06-18 | Security patches only | +| 0.18.x | [0.18.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.4) (2026-08-19) | 2025-06-18 | Security patches only | ## Maven Coordinates diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index 0a76c7a..1d111dd 100644 --- a/conformance-tests/client-jdk-http-client/pom.xml +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 2.0.1 + 2.1.0-SNAPSHOT client-jdk-http-client jar @@ -21,6 +21,7 @@ + io.modelcontextprotocol.sdk.conformance.client.jdk true @@ -28,7 +29,7 @@ io.modelcontextprotocol.sdk mcp - 2.0.1 + 2.1.0-SNAPSHOT diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml index 32c81ba..ccc721c 100644 --- a/conformance-tests/client-spring-http-client/pom.xml +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 2.0.1 + 2.1.0-SNAPSHOT client-spring-http-client jar @@ -21,6 +21,7 @@ + io.modelcontextprotocol.sdk.conformance.client.spring 17 4.1.0 2.0.0 diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index 8856e7d..7e411a2 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.1 + 2.1.0-SNAPSHOT conformance-tests pom diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml index df75a81..ce8f558 100644 --- a/conformance-tests/server-servlet/pom.xml +++ b/conformance-tests/server-servlet/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 2.0.1 + 2.1.0-SNAPSHOT server-servlet jar @@ -21,6 +21,7 @@ + io.modelcontextprotocol.sdk.conformance.server.servlet true @@ -28,7 +29,7 @@ io.modelcontextprotocol.sdk mcp - 2.0.1 + 2.1.0-SNAPSHOT diff --git a/docs/server.md b/docs/server.md index 8d74359..5948f4a 100644 --- a/docs/server.md +++ b/docs/server.md @@ -164,7 +164,10 @@ Key features: - Efficient bidirectional HTTP communication - Session management for multiple client connections - - Configurable keep-alive intervals + - Keep-alive pings on sessions with an open stream, enabled by default every 30 minutes + (`keepAliveInterval`, `null` to disable) + - Eviction of idle sessions — no open stream and no request for a full interval — every + 30 minutes by default (`sessionSweepInterval`, `null` to keep sessions until deleted) - Security validation support - Graceful shutdown support @@ -441,6 +444,102 @@ var syncToolSpecification = SyncToolSpecification.builder() `ImageContent.builder(data, mimeType)` and `AudioContent.builder(data, mimeType)` both take base64-encoded binary data. `EmbeddedResource.builder(resourceContents)` wraps either a `TextResourceContents` (for text data) or a `BlobResourceContents` (for base64-encoded binary data) — see [Reading Binary Resources](#reading-binary-resources) for the `BlobResourceContents` shape. +### Filtering the Tool Listing per Request + +By default every registered tool is advertised to every caller. Over an HTTP transport you can +vary the `tools/list` response per request — to hide tools the caller is not authorized to see, +or to trim a large catalog down to a relevant subset — by registering one or more tool filters. + +The filter receives the `McpTransportContext` extracted from the current request, so it can key +on HTTP headers, a token, a resolved principal, or anything else your +`contextExtractor` puts there. + +=== "Sync" + + ```java + McpServer.sync(transportProvider) + .tools(publicTool, adminTool) + .addToolFilter((transportContext, tool) -> + !tool.name().startsWith("admin-") || isAdmin(transportContext)) + .build(); + ``` + +=== "Async" + + ```java + McpServer.async(transportProvider) + .tools(publicTool, adminTool) + .addToolFilter((transportContext, tool) -> { + if (!tool.name().startsWith("admin-")) { + return Mono.just(true); + } + return isAdmin(transportContext); // Mono + }) + .build(); + ``` + +The same `addToolFilter(...)` method is available on the stateless builders. + +!!! warning "Hiding a tool does not make it unreachable" + + The filter controls **advertisement only**. A hidden tool called by name still executes: + you MUST enforce permissions in the tool's call handler. Use the filter to control what a + caller is told about, not what they are allowed to do. + +**Evaluation semantics** + +- The filter is consulted on **every** listing request and never cached, so the same session may + legitimately see different results for two successive requests carrying different credentials. +- Registration order is preserved; only omissions happen. +- Returning `Mono.empty()` from an async filter omits the tool. An error fails the whole listing + request rather than silently hiding tools: a client cannot tell a filtered-down listing from a + partial one, and MCP has no way to signal "this listing was incomplete, retry". +- A filter that errors is logged server-side and reported to the client as an opaque + `-32603 Internal error` with no `data`. If you want the client to see a specific error, throw an + `McpError`, those are passed through. +- Filters accumulate as a boolean **AND**: a tool is listed only when every registered filter accepts it, so a + later `addToolFilter(...)` can never widen access. Evaluation follows registration order and + short-circuits on the first filter that hides a tool. +- `toolFilters(Consumer>)` hands you the list of filters registered so far, so you can + inspect, reorder or clear them before building — useful when filters come from several places: + + ```java + McpServer.sync(transportProvider) + .addToolFilter(tenantFilter) + .toolFilters(filters -> filters.add(0, cheapDenyAllForAnonymousFilter)) + .build(); + ``` + +- Tools are tested one at a time, so a filter that performs I/O per tool costs one round trip per + tool. Sync filters also run on a shared scheduler thread — not the request thread — unless + `immediateExecution(true)` is set, so thread-bound request state (Spring Security's + `SecurityContextHolder`, MDC, custom `ThreadLocal` holders) is **not visible** inside the filter. + For both reasons, resolve per-request state **once** in the transport's `contextExtractor`, + which does run on the request thread, and read only the extracted context in the filter: + + ```java + // transport builder: one authorization lookup, on the request thread, + // shared by every tool tested in this request + var transportProvider = HttpServletStreamableServerTransportProvider.builder() + .contextExtractor(request -> McpTransportContext.create( + Map.of("perms", introspect(request.getHeader("Authorization"))))) + // ... + .build(); + + // server builder: the filter reads only the extracted context + McpServer.sync(transportProvider) + .addToolFilter((context, tool) -> + ((Set) context.get("perms")).contains(tool.name())) + .build(); + ``` + +- `notifications/tools/list_changed` is **not** filtered. It is a server-initiated broadcast with + no request in flight, so there is no context to evaluate. A client may be told something changed + when its own visible set did not; it gets the correct view on its next `tools/list`. Consider disabling + this notification entirely when using tool filters. +- With STDIO there is no per-request metadata, so the filter receives `McpTransportContext.EMPTY` and has nothing to key + on. + ### Resource Specification Specification of a resource with its handler function. diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index 9f4f9ff..4ee9f55 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.1 + 2.1.0-SNAPSHOT mcp-bom diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index b63b61b..79a417b 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.1 + 2.1.0-SNAPSHOT mcp-core jar @@ -20,6 +20,10 @@ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + + io.modelcontextprotocol.sdk.mcp.core + + @@ -37,7 +41,7 @@ Bundle-Name: Bundle ${project.groupId} : ${project.artifactId} Bundle-SymbolicName: ${project.groupId}.${project.artifactId} Bundle-Version: ${project.version} - Automatic-Module-Name: ${project.groupId}.${project.artifactId} + Automatic-Module-Name: ${module.name} Import-Package: jakarta.*;resolution:=optional, \ *; Service-Component: OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml 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 1af4eea..328566a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -494,9 +494,10 @@ public interface McpClient { * calling any client operation. This allows to extract thread-locals and hand * them over to the underlying transport. *

- * There is no direct equivalent in {@link AsyncSpec}. To achieve the same result, - * append {@code contextWrite(McpTransportContext.KEY, context)} to any - * {@link McpAsyncClient} call. + * The supplier is invoked at subscription time, on the calling thread, and the + * resulting context is visible to the transport for every leg of the operation, + * including connections opened on other threads, such as the SSE stream started + * during initialization. * @param contextProvider A supplier to create a context * @return This builder for method chaining */ @@ -580,6 +581,21 @@ public interface McpClient { *

  • Change notification handlers for tools, resources, and prompts *
  • Custom message sampling logic * + * + *

    + * Unlike {@link SyncSpec}, this specification has no + * {@code transportContextProvider}. This is deliberate: in a reactive pipeline the + * caller owns the Reactor context, and whatever the transport needs, such as an + * {@link McpTransportContext}, can be written into it directly. Write it once where + * the reactive chain starts, rather than at every call site, and every + * {@link McpAsyncClient} call downstream inherits it, including the connections + * opened during initialization:

    {@code
    +	 * chain.filter(exchange)
    +	 *     .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context));
    +	 * }
    To bridge thread-locals into the chain, use Reactor's context propagation + * support. + * + * @see SyncSpec#transportContextProvider(Supplier) */ class AsyncSpec { 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 07a8f5e..5517823 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 @@ -763,8 +763,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { 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_11_25); + private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2025_03_26, + ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); private McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientTransportAuthorizationErrorHandler.NOOP; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java index 73522ae..60a8d7c 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java @@ -258,16 +258,13 @@ public class StdioClientTransport implements McpClientTransport { @Override public Mono sendMessage(JSONRPCMessage message) { - if (this.outboundSink.tryEmitNext(message).isSuccess()) { - // TODO: essentially we could reschedule ourselves in some time and make - // another attempt with the already read data but pause reading until - // success - // In this approach we delegate the retry and the backpressure onto the - // caller. This might be enough for most cases. + try { + // busyLooping retries FAIL_NON_SERIALIZED under concurrent senders + this.outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); return Mono.empty(); } - else { - return Mono.error(new RuntimeException("Failed to enqueue message")); + catch (Sinks.EmissionException e) { + return Mono.error(new RuntimeException("Failed to enqueue message", e)); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java index 756b39c..f2951d8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java @@ -7,18 +7,26 @@ package io.modelcontextprotocol.client.transport.customizer; import java.net.URI; import java.net.http.HttpRequest; +import io.modelcontextprotocol.client.McpClient.SyncSpec; +import io.modelcontextprotocol.common.McpTransportContext; import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.util.annotation.Nullable; -import io.modelcontextprotocol.common.McpTransportContext; - /** * Customize {@link HttpRequest.Builder} before executing the request, in either SSE or * Streamable HTTP transport. *

    * When used in a non-blocking context, implementations MUST be non-blocking. + *

    + * The {@link McpTransportContext} handed to {@code customize} is read from the Reactor + * context, under {@link McpTransportContext#KEY}, and is + * {@link McpTransportContext#EMPTY} when the caller wrote nothing there. Write it once + * where the reactive chain starts, with + * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}, rather than at + * every call site. With a synchronous client, configure + * {@link SyncSpec#transportContextProvider} instead. * * @author Daniel Garnier-Moiroux */ diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java index 46a2ccf..4691c13 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java @@ -11,13 +11,28 @@ import java.util.Map; * Context associated with the transport layer. It allows to add transport-level metadata * for use further down the line. Specifically, it can be beneficial to extract HTTP * request metadata for use in MCP feature implementations. + *

    + * The context travels in the Reactor context, under {@link #KEY}. On the server side, the + * transports populate it from the incoming request. On the client side, writing it is the + * caller's responsibility: + *

      + *
    • with a synchronous client, configure + * {@code McpClient.SyncSpec#transportContextProvider(Supplier)}, which is invoked on the + * calling thread before every operation; + *
    • with an asynchronous client, write it into the Reactor context once, where the + * reactive chain starts, using + * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}. Every client + * call downstream inherits it, so there is no need to repeat it at each call site. + *
    * * @author Dariusz Jędrzejczyk */ public interface McpTransportContext { /** - * Key for use in Reactor Context to transport the context to user land. + * Key for use in Reactor Context to transport the context to user land. Write the + * context under this key to make it visible to the transport, for example + * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}. */ String KEY = "MCP_TRANSPORT_CONTEXT"; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java index 7eed339..6e89ab7 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java @@ -57,6 +57,11 @@ public interface JsonSchemaValidator { /** * Validates the structured content against the provided JSON schema. + *

    + * If {@code structuredContent} is a {@link String}, it is treated as a serialized + * JSON document and parsed before validation; quote embedded strings accordingly (for + * example {@code "\"red\""} for the JSON string value {@code red}). Any other type is + * converted to its JSON representation directly. * @param schema The JSON schema to validate against. * @param structuredContent The structured content to validate. * @return A ValidationResponse indicating whether the validation was successful or diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java new file mode 100644 index 0000000..d6b1858 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.util.List; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Decide per request whether a primitive is advertised in the corresponding listing, such + * as {@code tools/list}. + *

    + * A primitive hidden by this filter is omitted from listings ONLY. It remains reachable + * through its own endpoint: a hidden tool called by name still executes. Permissions MUST + * be enforced in the primitive's handler. + * + * @author Daniel Garnier-Moiroux + * @see McpSyncListFilter + * @see McpTransportContextExtractor + */ +@FunctionalInterface +public interface McpAsyncListFilter { + + /** + * Whether the given primitive is visible to the caller of the current request. + * @param transportContext transport context containing, for example, HTTP headers or + * a resolved principal. Should never be {@code null}, but may + * {@link McpTransportContext#EMPTY} for transports that carry no per-request + * metadata, such as STDIO. + * @param primitive the primitive that is a candidate for inclusion in the listing, + * such as {@link Tool}. + * @return a publisher emitting {@code true} to include the primitive in the listing, + * {@code false} to omit it. Completing empty omits the primitive; erroring fails the + * listing request. + */ + Mono isVisible(McpTransportContext transportContext, T primitive); + + /** + * Convert a potentially blocking, synchronous filter into an asynchronous one, + * offloading it to prevent accidental blocking of a non-blocking transport. + * @param filter the synchronous filter. MUST NOT be null. + * @param immediateExecution When true, do not offload work asynchronously. Do NOT set + * to true when the filter performs blocking I/O. + */ + static McpAsyncListFilter fromSync(McpSyncListFilter filter, boolean immediateExecution) { + Assert.notNull(filter, "filter must not be null"); + return (transportContext, primitive) -> { + var visible = Mono.fromCallable(() -> filter.isVisible(transportContext, primitive)); + return immediateExecution ? visible : visible.subscribeOn(Schedulers.boundedElastic()); + }; + } + + /** + * Combine multiple filters in a single AND-filter. An empty or {@code null} list + * makes everything visible, keeping listing on a single code path when nothing is + * configured. + * @param filters the filters to combine. May be {@code null} or empty, but MUST NOT + * contain {@code null} elements. + */ + static McpAsyncListFilter and(List> filters) { + if (filters == null || filters.isEmpty()) { + return (transportContext, primitive) -> Mono.just(Boolean.TRUE); + } + Assert.noNullElements(filters, "filters must not contain null elements"); + if (filters.size() == 1) { + return filters.get(0); + } + List> snapshot = List.copyOf(filters); + return (transportContext, primitive) -> Flux.fromIterable(snapshot) + .concatMap(filter -> filter.isVisible(transportContext, primitive).defaultIfEmpty(Boolean.FALSE)) + .all(Boolean.TRUE::equals); + } + +} 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 ac78c4f..fc1f8a4 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -118,6 +118,8 @@ public class McpAsyncServer { private final ConcurrentHashMap> resourceSubscriptions = new ConcurrentHashMap<>(); + private final McpAsyncListFilter toolFilter; + private List protocolVersions; private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory(); @@ -146,6 +148,7 @@ public class McpAsyncServer { this.uriTemplateManagerFactory = uriTemplateManagerFactory; this.jsonSchemaValidator = jsonSchemaValidator; this.validateToolInputs = validateToolInputs; + this.toolFilter = McpAsyncListFilter.and(features.toolFilters()); Map> requestHandlers = prepareRequestHandlers(); Map notificationHandlers = prepareNotificationHandlers(features); @@ -177,6 +180,7 @@ public class McpAsyncServer { this.uriTemplateManagerFactory = uriTemplateManagerFactory; this.jsonSchemaValidator = jsonSchemaValidator; this.validateToolInputs = validateToolInputs; + this.toolFilter = McpAsyncListFilter.and(features.toolFilters()); Map> requestHandlers = prepareRequestHandlers(); Map notificationHandlers = prepareNotificationHandlers(features); @@ -537,12 +541,32 @@ public class McpAsyncServer { private McpRequestHandler toolsListRequestHandler() { return (exchange, params) -> { - List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList(); - - return Mono.just(McpSchema.ListToolsResult.builder(tools).build()); + // TODO: Implement pagination. Cursors must be computed over the filtered + // view, otherwise page offsets leak the number of hidden tools. + return Flux.fromIterable(this.tools) + .map(McpServerFeatures.AsyncToolSpecification::tool) + .filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool) + .onErrorResume(error -> opaqueListFilterError(tool, error))) + .collectList() + .map(tools -> McpSchema.ListToolsResult.builder(tools).build()); }; } + /** + * Report a list filter failure to the client as an opaque {@code -32603} error, so + * that filter internals such as identity provider hostnames or the reason a principal + * was rejected never leave the server. The actual cause is logged instead. An + * {@link McpError} is deliberate on the filter's part and passes through untouched. + */ + private static Mono opaqueListFilterError(Tool tool, Throwable error) { + if (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + logger.debug("Tool list filter failed for tool '{}' with an explicit MCP error", tool.name(), error); + return Mono.error(mcpError); + } + logger.error("Tool list filter failed for tool '{}', failing the tools/list request", tool.name(), error); + return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR).message("Internal error").build()); + } + private McpRequestHandler toolsCallRequestHandler() { return (exchange, params) -> { McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params, 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 a2333ae..2113fdb 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import java.util.function.BiConsumer; import java.util.function.BiFunction; +import java.util.function.Consumer; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonDefaults; @@ -238,7 +239,7 @@ public interface McpServer { public McpAsyncServer build() { var features = new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers, - this.instructions); + this.instructions, this.toolFilters); var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); @@ -268,7 +269,7 @@ public interface McpServer { public McpAsyncServer build() { var features = new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers, - this.instructions); + this.instructions, this.toolFilters); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); @@ -301,6 +302,8 @@ public interface McpServer { boolean validateToolInputs = true; + final List> toolFilters = new ArrayList<>(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -440,6 +443,43 @@ public interface McpServer { return this; } + /** + * Adds a per-request filter deciding which tools are advertised in + * {@code tools/list}, for example to hide tools the caller is not authorized to + * see. Filters accumulate: a tool is listed only when every registered filter + * accepts it, so a later registration can never widen access. + *

    + * A hidden tool is omitted from listings only. It remains callable by name, so + * enforce permissions in the tool's call handler. Tools are NOT hidden from + * {@code notifications/tools/list_changed}, as it is a per-client context rather + * than per-request. Consider disabling list changed notifications entirely when + * using filters. + *

    + * @param toolFilter the filter to add, must not be null + * @return This builder instance for method chaining + * @see McpAsyncListFilter + */ + public AsyncSpecification addToolFilter(McpAsyncListFilter toolFilter) { + Assert.notNull(toolFilter, "Tool filter must not be null"); + this.toolFilters.add(toolFilter); + return this; + } + + /** + * Applies the given consumer to the list of registered tool filters, allowing + * them to be inspected, reordered or cleared before the server is built. + * @param toolFiltersConsumer consumer of the mutable list of registered filters, + * must not be null + * @return This builder instance for method chaining + * @see #addToolFilter(McpAsyncListFilter) + */ + public AsyncSpecification toolFilters( + Consumer>> toolFiltersConsumer) { + Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null"); + toolFiltersConsumer.accept(this.toolFilters); + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -830,7 +870,7 @@ public interface McpServer { public McpSyncServer build() { McpServerFeatures.Sync syncFeatures = new McpServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, - this.rootsChangeHandlers, this.instructions); + this.rootsChangeHandlers, this.instructions, this.toolFilters); McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); @@ -865,7 +905,7 @@ public interface McpServer { public McpSyncServer build() { McpServerFeatures.Sync syncFeatures = new McpServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, - this.rootsChangeHandlers, this.instructions); + this.rootsChangeHandlers, this.instructions, this.toolFilters); McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator @@ -900,6 +940,8 @@ public interface McpServer { boolean validateToolInputs = true; + final List> toolFilters = new ArrayList<>(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -1043,6 +1085,44 @@ public interface McpServer { return this; } + /** + * Adds a per-request filter deciding which tools are advertised in + * {@code tools/list}, for example to hide tools the caller is not authorized to + * see. Filters accumulate: a tool is listed only when every registered filter + * accepts it, so a later registration can never widen access. + *

    + * A hidden tool is omitted from listings only. It remains callable by name, so + * enforce permissions in the tool's call handler. Tools are NOT hidden from + * {@code notifications/tools/list_changed}, as it is a per-client context rather + * than per-request. Consider disabling list changed notifications entirely when + * using filters. + *

    + * The filter is offloaded to a bounded elastic scheduler unless + * {@link #immediateExecution(boolean)} is set. + * @param toolFilter the filter to add, must not be null + * @return This builder instance for method chaining + * @see McpSyncListFilter + */ + public SyncSpecification addToolFilter(McpSyncListFilter toolFilter) { + Assert.notNull(toolFilter, "Tool filter must not be null"); + this.toolFilters.add(toolFilter); + return this; + } + + /** + * Applies the given consumer to the list of registered tool filters, allowing + * them to be inspected, reordered or cleared before the server is built. + * @param toolFiltersConsumer consumer of the mutable list of registered filters, + * must not be null + * @return This builder instance for method chaining + * @see #addToolFilter(McpSyncListFilter) + */ + public SyncSpecification toolFilters(Consumer>> toolFiltersConsumer) { + Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null"); + toolFiltersConsumer.accept(this.toolFilters); + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -1442,6 +1522,8 @@ public interface McpServer { boolean validateToolInputs = true; + final List> toolFilters = new ArrayList<>(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -1582,6 +1664,40 @@ public interface McpServer { return this; } + /** + * Adds a per-request filter deciding which tools are advertised in + * {@code tools/list}, for example to hide tools the caller is not authorized to + * see. Filters accumulate: a tool is listed only when every registered filter + * accepts it, so a later registration can never widen access. + *

    + * A hidden tool is omitted from listings only. It remains callable by name, so + * enforce permissions in the tool's call handler. + *

    + * @param toolFilter the filter to add, must not be null + * @return This builder instance for method chaining + * @see McpAsyncListFilter + */ + public StatelessAsyncSpecification addToolFilter(McpAsyncListFilter toolFilter) { + Assert.notNull(toolFilter, "Tool filter must not be null"); + this.toolFilters.add(toolFilter); + return this; + } + + /** + * Applies the given consumer to the list of registered tool filters, allowing + * them to be inspected, reordered or cleared before the server is built. + * @param toolFiltersConsumer consumer of the mutable list of registered filters, + * must not be null + * @return This builder instance for method chaining + * @see #addToolFilter(McpAsyncListFilter) + */ + public StatelessAsyncSpecification toolFilters( + Consumer>> toolFiltersConsumer) { + Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null"); + toolFiltersConsumer.accept(this.toolFilters); + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -1908,7 +2024,8 @@ public interface McpServer { public McpStatelessAsyncServer build() { var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, - this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); + this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions, + this.toolFilters); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); @@ -1942,6 +2059,8 @@ public interface McpServer { boolean validateToolInputs = true; + final List> toolFilters = new ArrayList<>(); + /** * The Model Context Protocol (MCP) allows servers to expose tools that can be * invoked by language models. Tools enable models to interact with external @@ -2082,6 +2201,42 @@ public interface McpServer { return this; } + /** + * Adds a per-request filter deciding which tools are advertised in + * {@code tools/list}, for example to hide tools the caller is not authorized to + * see. Filters accumulate: a tool is listed only when every registered filter + * accepts it, so a later registration can never widen access. + *

    + * A hidden tool is omitted from listings only. It remains callable by name, so + * enforce permissions in the tool's call handler. + *

    + * The filter is offloaded to a bounded elastic scheduler unless + * {@link #immediateExecution(boolean)} is set. + * @param toolFilter the filter to add, must not be null + * @return This builder instance for method chaining + * @see McpSyncListFilter + */ + public StatelessSyncSpecification addToolFilter(McpSyncListFilter toolFilter) { + Assert.notNull(toolFilter, "Tool filter must not be null"); + this.toolFilters.add(toolFilter); + return this; + } + + /** + * Applies the given consumer to the list of registered tool filters, allowing + * them to be inspected, reordered or cleared before the server is built. + * @param toolFiltersConsumer consumer of the mutable list of registered filters, + * must not be null + * @return This builder instance for method chaining + * @see #addToolFilter(McpSyncListFilter) + */ + public StatelessSyncSpecification toolFilters( + Consumer>> toolFiltersConsumer) { + Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null"); + toolFiltersConsumer.accept(this.toolFilters); + return this; + } + /** * Sets the server capabilities that will be advertised to clients during * connection initialization. Capabilities define what features the server @@ -2424,7 +2579,8 @@ public interface McpServer { public McpStatelessSyncServer build() { var syncFeatures = new McpStatelessServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools, - this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); + this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions, + this.toolFilters); var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); 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 cfa28e6..77ecbc8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java @@ -38,6 +38,7 @@ public class McpServerFeatures { * @param rootsChangeConsumers The list of consumers that will be notified when the * roots list changes * @param instructions The server instructions text + * @param toolFilters The per-request filters deciding which tools are listed */ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, @@ -45,7 +46,7 @@ public class McpServerFeatures { Map prompts, Map completions, List, Mono>> rootsChangeConsumers, - String instructions) { + String instructions, List> toolFilters) { /** * Create an instance and validate the arguments. @@ -58,6 +59,7 @@ public class McpServerFeatures { * @param rootsChangeConsumers The list of consumers that will be notified when * the roots list changes * @param instructions The server instructions text + * @param toolFilters The per-request filters deciding which tools are listed */ Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, @@ -65,7 +67,7 @@ public class McpServerFeatures { Map prompts, Map completions, List, Mono>> rootsChangeConsumers, - String instructions) { + String instructions, List> toolFilters) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -91,6 +93,7 @@ public class McpServerFeatures { this.completions = (completions != null) ? completions : Map.of(); this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : List.of(); this.instructions = instructions; + this.toolFilters = (toolFilters != null) ? toolFilters : List.of(); } /** @@ -138,7 +141,11 @@ public class McpServerFeatures { } return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates, - prompts, completions, rootChangeConsumers, syncSpec.instructions()); + prompts, completions, rootChangeConsumers, syncSpec.instructions(), + syncSpec.toolFilters() + .stream() + .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution)) + .toList()); } } @@ -161,7 +168,8 @@ public class McpServerFeatures { Map resourceTemplates, Map prompts, Map completions, - List>> rootsChangeConsumers, String instructions) { + List>> rootsChangeConsumers, String instructions, + List> toolFilters) { /** * Create an instance and validate the arguments. @@ -174,6 +182,7 @@ public class McpServerFeatures { * @param rootsChangeConsumers The list of consumers that will be notified when * the roots list changes * @param instructions The server instructions text + * @param toolFilters The per-request filters deciding which tools are listed */ Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, @@ -181,8 +190,8 @@ public class McpServerFeatures { Map resourceTemplates, Map prompts, Map completions, - List>> rootsChangeConsumers, - String instructions) { + List>> rootsChangeConsumers, String instructions, + List> toolFilters) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -208,6 +217,7 @@ public class McpServerFeatures { this.completions = (completions != null) ? completions : new HashMap<>(); this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : new ArrayList<>(); this.instructions = instructions; + this.toolFilters = (toolFilters != null) ? toolFilters : List.of(); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 4211233..95b694e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -80,6 +80,8 @@ public class McpStatelessAsyncServer { private final boolean validateToolInputs; + private final McpAsyncListFilter toolFilter; + McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper, McpStatelessServerFeatures.Async features, Duration requestTimeout, McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator, @@ -97,6 +99,7 @@ public class McpStatelessAsyncServer { this.uriTemplateManagerFactory = uriTemplateManagerFactory; this.jsonSchemaValidator = jsonSchemaValidator; this.validateToolInputs = validateToolInputs; + this.toolFilter = McpAsyncListFilter.and(features.toolFilters()); Map> requestHandlers = new HashMap<>(); @@ -417,13 +420,32 @@ public class McpStatelessAsyncServer { private McpStatelessRequestHandler toolsListRequestHandler() { return (ctx, params) -> { - List tools = this.tools.stream() + // TODO: Implement pagination. Cursors must be computed over the filtered + // view, otherwise page offsets leak the number of hidden tools. + return Flux.fromIterable(this.tools) .map(McpStatelessServerFeatures.AsyncToolSpecification::tool) - .toList(); - return Mono.just(McpSchema.ListToolsResult.builder(tools).build()); + .filterWhen(tool -> this.toolFilter.isVisible(ctx, tool) + .onErrorResume(error -> opaqueListFilterError(tool, error))) + .collectList() + .map(tools -> McpSchema.ListToolsResult.builder(tools).build()); }; } + /** + * Report a list filter failure to the client as an opaque {@code -32603} error, so + * that filter internals such as identity provider hostnames or the reason a principal + * was rejected never leave the server. The actual cause is logged instead. An + * {@link McpError} is deliberate on the filter's part and passes through untouched. + */ + private static Mono opaqueListFilterError(Tool tool, Throwable error) { + if (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + logger.debug("Tool list filter failed for tool '{}' with an explicit MCP error", tool.name(), error); + return Mono.error(mcpError); + } + logger.error("Tool list filter failed for tool '{}', failing the tools/list request", tool.name(), error); + return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR).message("Internal error").build()); + } + private McpStatelessRequestHandler toolsCallRequestHandler() { return (ctx, params) -> { McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params, diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java index 0c1fbfb..8ffc2a6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java @@ -37,6 +37,7 @@ public class McpStatelessServerFeatures { * @param resourceTemplates The map of resource templates * @param prompts The map of prompt specifications * @param instructions The server instructions text + * @param toolFilters The per-request filters deciding which tools are listed */ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, @@ -44,7 +45,7 @@ public class McpStatelessServerFeatures { Map resourceTemplates, Map prompts, Map completions, - String instructions) { + String instructions, List> toolFilters) { /** * Create an instance and validate the arguments. @@ -55,6 +56,7 @@ public class McpStatelessServerFeatures { * @param resourceTemplates The map of resource templates * @param prompts The map of prompt specifications * @param instructions The server instructions text + * @param toolFilters The per-request filters deciding which tools are listed */ Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, @@ -62,7 +64,7 @@ public class McpStatelessServerFeatures { Map resourceTemplates, Map prompts, Map completions, - String instructions) { + String instructions, List> toolFilters) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -84,6 +86,7 @@ public class McpStatelessServerFeatures { this.prompts = (prompts != null) ? prompts : Map.of(); this.completions = (completions != null) ? completions : Map.of(); this.instructions = instructions; + this.toolFilters = (toolFilters != null) ? toolFilters : List.of(); } /** @@ -123,7 +126,11 @@ public class McpStatelessServerFeatures { }); return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates, - prompts, completions, syncSpec.instructions()); + prompts, completions, syncSpec.instructions(), + syncSpec.toolFilters() + .stream() + .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution)) + .toList()); } } @@ -137,6 +144,7 @@ public class McpStatelessServerFeatures { * @param resourceTemplates The map of resource templates * @param prompts The map of prompt specifications * @param instructions The server instructions text + * @param toolFilters The per-request filters deciding which tools are listed */ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, @@ -144,7 +152,7 @@ public class McpStatelessServerFeatures { Map resourceTemplates, Map prompts, Map completions, - String instructions) { + String instructions, List> toolFilters) { /** * Create an instance and validate the arguments. @@ -155,6 +163,7 @@ public class McpStatelessServerFeatures { * @param resourceTemplates The map of resource templates * @param prompts The map of prompt specifications * @param instructions The server instructions text + * @param toolFilters The per-request filters deciding which tools are listed */ Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, @@ -162,7 +171,7 @@ public class McpStatelessServerFeatures { Map resourceTemplates, Map prompts, Map completions, - String instructions) { + String instructions, List> toolFilters) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -187,6 +196,7 @@ public class McpStatelessServerFeatures { this.prompts = (prompts != null) ? prompts : new HashMap<>(); this.completions = (completions != null) ? completions : new HashMap<>(); this.instructions = instructions; + this.toolFilters = (toolFilters != null) ? toolFilters : List.of(); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java new file mode 100644 index 0000000..01d4d07 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Decide per request whether a primitive is advertised in the corresponding listing, such + * as {@code tools/list}. + *

    + * A primitive hidden by this filter is omitted from listings ONLY. It remains reachable + * through its own endpoint: a hidden tool called by name still executes. Permissions MUST + * be enforced in the primitive's handler. + * + * @author Daniel Garnier-Moiroux + * @see McpAsyncListFilter + * @see McpTransportContextExtractor + */ +@FunctionalInterface +public interface McpSyncListFilter { + + /** + * Whether the given primitive is visible to the caller of the current request. + * @param transportContext transport context containing, for example, HTTP headers or + * a resolved principal. Should never be {@code null}, but may + * {@link McpTransportContext#EMPTY} for transports that carry no per-request + * metadata, such as STDIO. + * @param primitive the primitive that is a candidate for inclusion in the listing, + * such as {@link McpSchema.Tool}. + * @return {@code true} to include the primitive in the listing, {@code false} to omit + * it. Throwing an exception fails the listing request. + */ + boolean isVisible(McpTransportContext transportContext, T primitive); + +} 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 index e96403e..725a67a 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java @@ -7,6 +7,7 @@ package io.modelcontextprotocol.server.transport; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import io.modelcontextprotocol.util.Assert; @@ -22,7 +23,8 @@ import io.modelcontextprotocol.util.Assert; * @see ServerTransportSecurityValidator * @see ServerTransportSecurityException */ -public final class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator { +public final class DefaultServerTransportSecurityValidator + implements ServerTransportSecurityValidator, ServerHttpHeaderValidator { private static final String ORIGIN_HEADER = "Origin"; @@ -47,27 +49,24 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran } @Override + @Deprecated 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)); - } + validate(new MapHeaderAccessor(headers)); + } + + @Override + public void validate(HeaderAccessor headerAccessor) throws ServerTransportSecurityException { + List originValues = headerAccessor.getHeader(ORIGIN_HEADER); + if (originValues != null && !originValues.isEmpty()) { + validateOrigin(originValues.get(0)); } - if (!allowedHosts.isEmpty() && missingHost) { - throw new ServerTransportSecurityException(421, "Invalid Host header"); + + if (!allowedHosts.isEmpty()) { + List hostValues = headerAccessor.getHeader(HOST_HEADER); + if (hostValues == null || hostValues.isEmpty()) { + throw new ServerTransportSecurityException(421, "Invalid Host header"); + } + validateHost(hostValues.get(0)); } } @@ -139,6 +138,37 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran return new Builder(); } + /** + * {@link HeaderAccessor} view over a {@code Map>}, used to + * bridge the deprecated {@link #validateHeaders(Map)} to + * {@link #validate(HeaderAccessor)}. + */ + private static final class MapHeaderAccessor implements HeaderAccessor { + + private final Map> headers; + + private MapHeaderAccessor(Map> headers) { + this.headers = headers; + } + + @Override + public List getHeader(String name) { + return headers.entrySet() + .stream() + .filter(entry -> entry.getKey().equalsIgnoreCase(name)) + .map(Map.Entry::getValue) + .filter(Objects::nonNull) + .findFirst() + .orElse(List.of()); + } + + @Override + public List getHeaderNames() { + return List.copyOf(headers.keySet()); + } + + } + /** * Builder for creating instances of {@link DefaultServerTransportSecurityValidator}. */ diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java new file mode 100644 index 0000000..efed4a7 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.List; + +/** + * Abstraction for accessing HTTP headers from an incoming request. Implementations should + * provide case-insensitive header name lookups (e.g., when backed by + * {@code HttpServletRequest}). + * + * @author Neeraj Bhatt + * @since 2.1.0 + * @see ServerHttpHeaderValidator + */ +public interface HeaderAccessor { + + /** + * Returns the values of the specified header, or an empty list if the header is not + * present. + * @param name the header name (case-insensitive) + * @return the list of header values, never {@code null} + */ + List getHeader(String name); + + /** + * Returns all header names present in the request. + * @return the list of header names, never {@code null} + */ + List getHeaderNames(); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java new file mode 100644 index 0000000..a06261c --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.util.Collections; +import java.util.List; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * {@link HeaderAccessor} implementation backed by an {@link HttpServletRequest}. Header + * name lookups are case-insensitive as per the Servlet specification. + * + *

    + * For internal use only. + * + * @author Neeraj Bhatt + * @since 2.1.0 + * @see HeaderAccessor + */ +final class HttpServletHeaderAccessor implements HeaderAccessor { + + private final HttpServletRequest request; + + HttpServletHeaderAccessor(HttpServletRequest request) { + this.request = request; + } + + @Override + public List getHeader(String name) { + return Collections.list(this.request.getHeaders(name)); + } + + @Override + public List getHeaderNames() { + return Collections.list(this.request.getHeaderNames()); + } + +} 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 index 721bdf1..5901c38 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java @@ -8,11 +8,6 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; -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; @@ -26,21 +21,6 @@ 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; - } - /** * Reads the request body, decoded using the request's character encoding (or UTF-8 if * not specified), while bounding the number of bytes read. 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 05dd862..a14271a 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 @@ -161,7 +161,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement /** * Security validator for validating HTTP requests. */ - private final ServerTransportSecurityValidator securityValidator; + private final ServerHttpHeaderValidator httpHeaderValidator; /** * Creates a new HttpServletSseServerTransportProvider instance with a custom SSE @@ -174,20 +174,20 @@ 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. - * @param securityValidator The security validator for validating HTTP requests. + * @param httpHeaderValidator The HTTP header validator for validating HTTP requests. * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be * positive. */ private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint, String sseEndpoint, Duration keepAliveInterval, McpTransportContextExtractor contextExtractor, - ServerTransportSecurityValidator securityValidator, int requestMaxSize) { + ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) { 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"); + Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null"); Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive"); this.jsonMapper = jsonMapper; @@ -195,7 +195,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement this.messageEndpoint = messageEndpoint; this.sseEndpoint = sseEndpoint; this.contextExtractor = contextExtractor; - this.securityValidator = securityValidator; + this.httpHeaderValidator = httpHeaderValidator; this.requestMaxSize = requestMaxSize; if (keepAliveInterval != null) { @@ -293,8 +293,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement } try { - Map> headers = HttpServletRequestUtils.extractHeaders(request); - this.securityValidator.validateHeaders(headers); + this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -371,8 +370,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement } try { - Map> headers = HttpServletRequestUtils.extractHeaders(request); - this.securityValidator.validateHeaders(headers); + this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -619,7 +617,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement private Duration keepAliveInterval; - private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP; private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; @@ -702,10 +700,25 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement * @param securityValidator The security validator to use. Must not be null. * @return This builder instance * @throws IllegalArgumentException if securityValidator is null + * @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)} + * instead. */ + @Deprecated public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { Assert.notNull(securityValidator, "Security validator must not be null"); - this.securityValidator = securityValidator; + this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator); + return this; + } + + /** + * Sets the HTTP header validator for validating HTTP requests. + * @param httpHeaderValidator The HTTP header validator to use. Must not be null. + * @return This builder instance + * @throws IllegalArgumentException if httpHeaderValidator is null + */ + public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) { + Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null"); + this.httpHeaderValidator = httpHeaderValidator; return this; } @@ -734,7 +747,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement } return new HttpServletSseServerTransportProvider( jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint, - sseEndpoint, keepAliveInterval, contextExtractor, securityValidator, requestMaxSize); + sseEndpoint, keepAliveInterval, contextExtractor, httpHeaderValidator, requestMaxSize); } } 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 54f0ac0..9cd0d04 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 @@ -7,7 +7,6 @@ package io.modelcontextprotocol.server.transport; import java.io.IOException; import java.io.PrintWriter; import java.util.List; -import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,7 +67,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements /** * Security validator for validating HTTP requests. */ - private final ServerTransportSecurityValidator securityValidator; + private final ServerHttpHeaderValidator httpHeaderValidator; /** * Maximum size, in bytes, of a single request body accepted by this transport. @@ -82,24 +81,24 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements * @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC * messages. * @param contextExtractor The extractor for transport context from the request. - * @param securityValidator The security validator for validating HTTP requests. + * @param httpHeaderValidator The HTTP header validator for validating HTTP requests. * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be * positive. * @throws IllegalArgumentException if any parameter is null */ private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint, McpTransportContextExtractor contextExtractor, - ServerTransportSecurityValidator securityValidator, int requestMaxSize) { + ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) { 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"); + Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null"); Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.contextExtractor = contextExtractor; - this.securityValidator = securityValidator; + this.httpHeaderValidator = httpHeaderValidator; this.requestMaxSize = requestMaxSize; } @@ -162,8 +161,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements } try { - Map> headers = HttpServletRequestUtils.extractHeaders(request); - this.securityValidator.validateHeaders(headers); + this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -300,7 +298,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; - private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP; private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; @@ -354,10 +352,25 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements * @param securityValidator The security validator to use. Must not be null. * @return this builder instance * @throws IllegalArgumentException if securityValidator is null + * @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)} + * instead. */ + @Deprecated public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { Assert.notNull(securityValidator, "Security validator must not be null"); - this.securityValidator = securityValidator; + this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator); + return this; + } + + /** + * Sets the HTTP header validator for validating HTTP requests. + * @param httpHeaderValidator The HTTP header validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if httpHeaderValidator is null + */ + public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) { + Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null"); + this.httpHeaderValidator = httpHeaderValidator; return this; } @@ -384,7 +397,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements Assert.notNull(mcpEndpoint, "Message endpoint must be set"); return new HttpServletStatelessServerTransport( jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor, - securityValidator, requestMaxSize); + httpHeaderValidator, requestMaxSize); } } 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 324a2ec..5f883cb 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 @@ -9,8 +9,9 @@ import java.io.PrintWriter; import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import io.modelcontextprotocol.common.McpTransportContext; @@ -21,12 +22,17 @@ import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.HttpHeaders; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSession; import io.modelcontextprotocol.spec.McpStreamableServerSession; import io.modelcontextprotocol.spec.McpStreamableServerTransport; import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpTransportException; +import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; @@ -34,8 +40,10 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; 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.scheduler.Schedulers; /** * Server-side implementation of the Model Context Protocol (MCP) streamable transport @@ -52,6 +60,7 @@ import reactor.core.publisher.Mono; * @author Zachary German * @author Christian Tzolov * @author Dariusz Jędrzejczyk + * @author Daniel Garnier-Moiroux * @see McpStreamableServerTransportProvider * @see HttpServlet */ @@ -114,6 +123,14 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet */ private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + /** + * IDs of the sessions which received a request since the last sweep. The set is + * swapped for an empty one on every sweep, so it only ever holds the activity of the + * current interval. Only populated when a sweeper runs, as nothing would ever swap it + * otherwise. + */ + private final AtomicReference> activeSessions = new AtomicReference<>(ConcurrentHashMap.newKeySet()); + private McpTransportContextExtractor contextExtractor; /** @@ -127,10 +144,18 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet */ private KeepAliveScheduler keepAliveScheduler; + /** + * Periodic eviction of the sessions no client came back to. {@code null} if no + * sessionSweepInterval is set, in which case sessions are never reclaimed. + */ + private final Disposable sessionSweeper; + + private static Duration SESSION_SWEEP_TIMEOUT = Duration.ofSeconds(30); + /** * Security validator for validating HTTP requests. */ - private final ServerTransportSecurityValidator securityValidator; + private final ServerHttpHeaderValidator httpHeaderValidator; /** * Constructs a new HttpServletStreamableServerTransportProvider instance. @@ -142,38 +167,122 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet * @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. + * @param httpHeaderValidator The HTTP header validator for validating HTTP requests. * @param requestMaxSize The maximum size, in bytes, of a single request body. Must be * positive. + * @param sessionSweepInterval The interval at which idle sessions are evicted. If + * null, no sweeping will be scheduled. * @throws IllegalArgumentException if any parameter is null */ private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint, boolean disallowDelete, McpTransportContextExtractor contextExtractor, - Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator, int requestMaxSize) { + Duration keepAliveInterval, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize, + Duration sessionSweepInterval) { 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"); + Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null"); Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.disallowDelete = disallowDelete; this.contextExtractor = contextExtractor; - this.securityValidator = securityValidator; + this.httpHeaderValidator = httpHeaderValidator; this.requestMaxSize = requestMaxSize; if (keepAliveInterval != null) { - this.keepAliveScheduler = KeepAliveScheduler - .builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(sessions.values())) + this.keepAliveScheduler = KeepAliveScheduler.builder(this::sessionsToPing) .initialDelay(keepAliveInterval) .interval(keepAliveInterval) + .onPingFailure(session -> { + // The stream the ping was written to is dead. The session survives: + // the client may reconnect to it, and the idle timeout reclaims it if + // it never does. + if (session instanceof McpStreamableServerSession streamableSession) { + streamableSession.releaseListeningStream(); + } + }) .build(); this.keepAliveScheduler.start(); } + this.sessionSweeper = sessionSweepInterval == null ? null : startSessionSweeper(sessionSweepInterval); + } + + /** + * Schedules the periodic eviction of idle sessions. + * @param sessionSweepInterval the interval between two sweeps + * @return the handle on the scheduled sweeps, to be disposed when the transport shuts + * down + */ + private Disposable startSessionSweeper(Duration sessionSweepInterval) { + return Flux + .interval(sessionSweepInterval, sessionSweepInterval, + Schedulers.newSingle("streamable-http-server-transport-session-sweeper")) + .concatMap( + tick -> sweepSessions().doOnError(e -> logger.error("Session sweep failed", e)).onErrorComplete()) + .subscribe(next -> { + }, error -> logger.error("Session sweeper error", error)); + } + + /** + * Evicts the sessions no client is using anymore. A session is kept if it holds an + * open stream, which a client can legitimately sit on without ever writing to it, or + * if it received a request during the interval which just elapsed. Anything else is a + * session whose client went away without deleting it: the protocol lets a client + * reconnect to a session, so nothing else ever reclaims it. + * @return a Mono completing once every evicted session has been closed + */ + private Mono sweepSessions() { + if (this.isClosing) { + return Mono.empty(); + } + Set active = this.activeSessions.getAndSet(ConcurrentHashMap.newKeySet()); + return Flux.fromIterable(this.sessions.values()).filter(session -> { + if (session.hasOpenStream() || active.contains(session.getId())) { + return false; + } + return this.sessions.remove(session.getId(), session); + }).flatMap(session -> { + logger.debug("Evicting idle session {}", session.getId()); + return session.closeGracefully() + .timeout(SESSION_SWEEP_TIMEOUT) + .doOnError(e -> logger.warn("Failed to close idle session {}: {}", session.getId(), e.getMessage())) + .onErrorComplete(); + }).then(); + } + + /** + * Records that the given session is being used, so that the next sweep does not + * mistake it for a session whose client is gone. + * @param sessionId the session the current request belongs to + */ + private void markSessionActive(String sessionId) { + if (this.sessionSweeper == null) { + // No sweep ever swaps the set of active sessions, so recording activity would + // only accumulate the ID of every session which ever issued a request + return; + } + this.activeSessions.get().add(sessionId); + } + + /** + * Returns the sessions a keep-alive ping can be sent to, that is the sessions having + * a listening stream. A session without one, e.g. a client which only ever issues + * POST requests, has nothing to write a ping to: pinging it would fail on every + * interval without ever telling us anything about the client being alive. + * @return the sessions to ping + */ + private Flux sessionsToPing() { + if (this.isClosing) { + return Flux.empty(); + } + return Flux.fromIterable(this.sessions.values()) + .filter(McpStreamableServerSession::hasListeningStream) + .cast(McpSession.class); } @Override @@ -248,6 +357,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet if (this.keepAliveScheduler != null) { this.keepAliveScheduler.shutdown(); } + if (this.sessionSweeper != null) { + this.sessionSweeper.dispose(); + } }); } @@ -274,8 +386,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet } try { - Map> headers = HttpServletRequestUtils.extractHeaders(request); - this.securityValidator.validateHeaders(headers); + this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -308,6 +419,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } + this.markSessionActive(sessionId); logger.debug("Handling GET request for session: {}", sessionId); @@ -325,61 +437,21 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport( sessionId, asyncContext, response.getWriter()); - // Check if this is a replay request - if (request.getHeader(HttpHeaders.LAST_EVENT_ID) != null) { - String lastId = request.getHeader(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()); - asyncContext.complete(); - } - }); - } - catch (Exception e) { - logger.error("Failed to replay messages: {}", e.getMessage()); - asyncContext.complete(); - } + // Replay the messages the client missed while its stream was broken + String lastEventId = request.getHeader(HttpHeaders.LAST_EVENT_ID); + if (lastEventId != null + && !this.tryReplayMissedMessages(session, lastEventId, sessionTransport, transportContext)) { + // The replay failed and already closed the transport + return; } - else { - // Establish new listening stream - McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session - .listeningStream(sessionTransport); - asyncContext.addListener(new jakarta.servlet.AsyncListener() { - @Override - public void onComplete(jakarta.servlet.AsyncEvent event) throws IOException { - logger.debug("SSE connection completed for session: {}", sessionId); - listeningStream.close(); - } + // Establish the listening stream. Resumed streams are registered too, so + // that the session keeps delivering messages to the reconnected client and + // the async context is completed once the client goes away. + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session + .listeningStream(sessionTransport); - @Override - public void onTimeout(jakarta.servlet.AsyncEvent event) throws IOException { - logger.debug("SSE connection timed out for session: {}", sessionId); - listeningStream.close(); - } - - @Override - public void onError(jakarta.servlet.AsyncEvent event) throws IOException { - logger.debug("SSE connection error for session: {}", sessionId); - listeningStream.close(); - } - - @Override - public void onStartAsync(jakarta.servlet.AsyncEvent event) throws IOException { - // No action needed - } - }); - } + registerAsyncLifecycle(asyncContext, sessionId, listeningStream::releaseTransport); } catch (Exception e) { logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); @@ -414,8 +486,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet } try { - Map> headers = HttpServletRequestUtils.extractHeaders(request); - this.securityValidator.validateHeaders(headers); + this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -454,6 +525,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet }); McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory .startSession(initializeRequest); + this.markSessionActive(init.session().getId()); this.sessions.put(init.session().getId(), init.session()); try { @@ -505,6 +577,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet return; } + this.markSessionActive(sessionId); + if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { session.accept(jsonrpcResponse) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) @@ -530,8 +604,16 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport( sessionId, asyncContext, response.getWriter()); + // The listener is given the stream rather than its transport, so that the + // end of the connection detaches the stream from the session instead of + // only dropping the socket: a stream outliving its connection keeps the + // session looking busy and spares it from the sweeper + McpStreamableServerSession.McpStreamableServerSessionStream responseStream = session + .responseStream(sessionTransport); + registerAsyncLifecycle(asyncContext, sessionId, responseStream::releaseTransport); + try { - session.responseStream(jsonrpcRequest, sessionTransport) + responseStream.handle(jsonrpcRequest) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); } @@ -593,8 +675,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet } try { - Map> headers = HttpServletRequestUtils.extractHeaders(request); - this.securityValidator.validateHeaders(headers); + this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -686,6 +767,70 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet super.destroy(); } + /** + * Replays the messages the client missed while its SSE stream was broken. + * @param session the session the client is resuming + * @param lastEventId the ID of the last event received by the client + * @param sessionTransport the transport of the resumed SSE stream + * @param transportContext the context extracted from the request + * @return {@code true} if the replay completed, {@code false} if it failed, in which + * case the transport has been closed + */ + private static boolean tryReplayMissedMessages(McpStreamableServerSession session, String lastEventId, + McpStreamableServerTransport sessionTransport, McpTransportContext transportContext) { + try { + for (McpSchema.JSONRPCMessage message : session.replay(lastEventId) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .toIterable()) { + sessionTransport.sendMessage(message) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + } + return true; + } + catch (Exception e) { + logger.error("Failed to replay messages for session {}: {}", session.getId(), e.getMessage()); + sessionTransport.close(); + return false; + } + } + + /** + * Registers a listener releasing the SSE stream carried by the given asynchronous + * request once the container is done with it, whether the client went away, the + * request timed out or it errored. Without this, the connection is left open, holding + * on to a socket and a container thread, until the process restarts. + * @param asyncContext the asynchronous context of the SSE request + * @param sessionId the session the stream belongs to + * @param onConnectionEnd the action releasing the stream + */ + private static void registerAsyncLifecycle(AsyncContext asyncContext, String sessionId, Runnable onConnectionEnd) { + asyncContext.addListener(new AsyncListener() { + @Override + public void onComplete(AsyncEvent event) throws IOException { + logger.debug("SSE connection completed for session: {}", sessionId); + onConnectionEnd.run(); + } + + @Override + public void onTimeout(AsyncEvent event) throws IOException { + logger.debug("SSE connection timed out for session: {}", sessionId); + onConnectionEnd.run(); + } + + @Override + public void onError(AsyncEvent event) throws IOException { + logger.debug("SSE connection error for session: {}", sessionId); + onConnectionEnd.run(); + } + + @Override + public void onStartAsync(AsyncEvent event) throws IOException { + // No action needed + } + }); + } + /** * Implementation of McpStreamableServerTransport for HttpServlet SSE sessions. This * class handles the transport-level communication for a specific client session. @@ -695,7 +840,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet * underlying PrintWriter to prevent race conditions when multiple threads attempt to * send messages concurrently. */ - private class HttpServletStreamableMcpSessionTransport implements McpStreamableServerTransport { private final String sessionId; @@ -759,9 +903,17 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId); } catch (Exception e) { + // The connection is gone, the session is not: the client may come + // back for it, and the sweeper reclaims it if it never does logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); - HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId); - this.asyncContext.complete(); + this.close(); + // Surfaced to the caller rather than swallowed: whoever is writing to + // this stream has to learn that it no longer leads anywhere, or it + // keeps producing messages for a client which is gone. A request + // being streamed a response would never finish, holding on to the + // container thread which has to be given back before the end of the + // connection can be acted upon. + throw new McpTransportException("Failed to send message to session " + this.sessionId, e); } finally { lock.unlock(); @@ -805,8 +957,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet } this.closed = true; - - // HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId); this.asyncContext.complete(); logger.debug("Successfully completed async context for session {}", sessionId); } @@ -820,6 +970,12 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet } + @Override + public List protocolVersions() { + return List.of(ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, + ProtocolVersions.MCP_2025_11_25); + } + public static Builder builder() { return new Builder(); } @@ -839,12 +995,14 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; - private Duration keepAliveInterval; + private Duration keepAliveInterval = Duration.ofMinutes(30); - private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; + private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP; private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; + private Duration sessionSweepInterval = Duration.ofMinutes(30); + /** * Sets the JsonMapper to use for JSON serialization/deserialization of MCP * messages. @@ -896,7 +1054,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet * Sets the keep-alive interval for the transport. If set, a keep-alive scheduler * will be activated to periodically ping active sessions. * @param keepAliveInterval The interval for keep-alive pings. If null, no - * keep-alive will be scheduled. + * keep-alive will be scheduled. Defaults to 30 minutes. * @return this builder instance */ public Builder keepAliveInterval(Duration keepAliveInterval) { @@ -909,10 +1067,25 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet * @param securityValidator The security validator to use. Must not be null. * @return this builder instance * @throws IllegalArgumentException if securityValidator is null + * @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)} + * instead. */ + @Deprecated public Builder securityValidator(ServerTransportSecurityValidator securityValidator) { Assert.notNull(securityValidator, "Security validator must not be null"); - this.securityValidator = securityValidator; + this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator); + return this; + } + + /** + * Sets the HTTP header validator for validating HTTP requests. + * @param httpHeaderValidator The HTTP header validator to use. Must not be null. + * @return this builder instance + * @throws IllegalArgumentException if httpHeaderValidator is null + */ + public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) { + Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null"); + this.httpHeaderValidator = httpHeaderValidator; return this; } @@ -929,6 +1102,22 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet return this; } + /** + * Sets the interval at which idle sessions are evicted. A session is idle once it + * holds no open stream and has received no request for a full interval, which is + * how a session whose client went away without deleting it looks. Nothing else + * reclaims those sessions, as the protocol lets a client reconnect to a session + * it has been disconnected from. + * @param sessionSweepInterval The interval between two sweeps. If null, no + * sweeping will be scheduled and sessions are kept until they are deleted or the + * server shuts down. Defaults to 30 minutes. + * @return this builder instance + */ + public Builder sessionSweepInterval(Duration sessionSweepInterval) { + this.sessionSweepInterval = sessionSweepInterval; + return this; + } + /** * Builds a new instance of {@link HttpServletStreamableServerTransportProvider} * with the configured settings. @@ -939,7 +1128,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set"); return new HttpServletStreamableServerTransportProvider( jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete, - contextExtractor, keepAliveInterval, securityValidator, requestMaxSize); + contextExtractor, keepAliveInterval, httpHeaderValidator, requestMaxSize, sessionSweepInterval); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java new file mode 100644 index 0000000..6f2a3dc --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +/** + * Validates HTTP request headers in server transports. + * + * @author Neeraj Bhatt + * @see HeaderAccessor + * @see ServerTransportSecurityException + */ +@FunctionalInterface +public interface ServerHttpHeaderValidator { + + /** + * A no-op validator that accepts all requests without validation. + */ + ServerHttpHeaderValidator NOOP = headerAccessor -> { + }; + + /** + * Validates the HTTP headers from an incoming request. + * @param headerAccessor provides access to request headers + * @throws ServerTransportSecurityException if validation fails + */ + void validate(HeaderAccessor headerAccessor) throws ServerTransportSecurityException; + +} 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 index ce80593..023f7f3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java @@ -6,6 +6,8 @@ package io.modelcontextprotocol.server.transport; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; /** * Interface for validating HTTP requests in server transports. Implementations can @@ -13,9 +15,12 @@ import java.util.Map; * to the MCP specification. * * @author Daniel Garnier-Moiroux - * @see DefaultServerTransportSecurityValidator + * @see ServerHttpHeaderValidator * @see ServerTransportSecurityException + * @deprecated Use {@link ServerHttpHeaderValidator} instead. This interface will be + * removed in a future major version. */ +@Deprecated @FunctionalInterface public interface ServerTransportSecurityValidator { @@ -25,6 +30,20 @@ public interface ServerTransportSecurityValidator { ServerTransportSecurityValidator NOOP = headers -> { }; + /** + * Adapts a legacy map-based validator to a header accessor validator. + * @param validator the legacy validator to adapt + * @return a header accessor validator delegating to {@code validator} + */ + static ServerHttpHeaderValidator toHttpHeaderValidator(ServerTransportSecurityValidator validator) { + return accessor -> { + var collectedHeaders = accessor.getHeaderNames() + .stream() + .collect(Collectors.toUnmodifiableMap(Function.identity(), accessor::getHeader)); + validator.validateHeaders(collectedHeaders); + }; + } + /** * Validates the HTTP headers from an incoming request. * @param headers A map of header names to their values (multi-valued headers 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 cc51233..8370e41 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 @@ -10,13 +10,13 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import io.modelcontextprotocol.json.TypeRef; -import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage; import io.modelcontextprotocol.spec.McpServerSession; @@ -175,11 +175,12 @@ public class StdioServerTransportProvider implements McpServerTransportProvider public Mono sendMessage(McpSchema.JSONRPCMessage message) { return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> { - if (outboundSink.tryEmitNext(message).isSuccess()) { + try { + outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); return Mono.empty(); } - else { - return Mono.error(new RuntimeException("Failed to enqueue message")); + catch (Sinks.EmissionException e) { + return Mono.error(new RuntimeException("Failed to enqueue message", e)); } })); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java index e7fac7b..5ac0863 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java @@ -1,29 +1,31 @@ /* - * Copyright 2024-2025 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.spec; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; import java.util.function.Supplier; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.modelcontextprotocol.json.TypeRef; - import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.schema.JsonSchemaValidator; import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpNotificationHandler; import io.modelcontextprotocol.server.McpRequestHandler; import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.MonoSink; @@ -36,12 +38,26 @@ import reactor.core.publisher.MonoSink; * * @author Dariusz Jędrzejczyk * @author Yanming Zhou + * @author Daniel Garnier-Moiroux */ public class McpStreamableServerSession implements McpLoggableSession { private static final Logger logger = LoggerFactory.getLogger(McpStreamableServerSession.class); - private final ConcurrentHashMap requestIdToStream = new ConcurrentHashMap<>(); + /** + * Every server-initiated request still awaiting its response, keyed by request ID. + * Tracked per session rather than per stream, because the client answers over a + * separate HTTP POST which outlives the stream the request was sent on: a stream + * going away must not lose the requests it carried, but the end of the session must + * resolve all of them. + */ + private final ConcurrentHashMap pendingRequests = new ConcurrentHashMap<>(); + + /** + * Every stream with a connection currently attached, whether the listening stream or + * a POST response stream, so that they can all be released when the session ends. + */ + private final Set openStreams = ConcurrentHashMap.newKeySet(); private final String id; @@ -179,65 +195,91 @@ public class McpStreamableServerSession implements McpLoggableSession { } /** - * Create a listening stream (the generic HTTP GET request without Last-Event-ID - * header). + * Whether the session currently has a listening stream, that is a stream the server + * can send its own requests and notifications to. Sessions have none until the client + * issues the GET request establishing one, and clients are not required to ever issue + * it. + * @return {@code true} if the session has a listening stream + */ + public boolean hasListeningStream() { + return this.listeningStreamRef.get() instanceof McpStreamableServerSessionStream; + } + + /** + * Whether the session currently has at least one stream with a connection attached, + * whether the listening stream or a POST response stream. A client holding such a + * connection open is still there, however long it stays silent on it. + * @return {@code true} if the session has an open stream + */ + public boolean hasOpenStream() { + return !this.openStreams.isEmpty(); + } + + /** + * Create a listening stream (the generic HTTP GET request, with or without a + * Last-Event-ID header). A session addresses a single listening stream at a time, so + * the stream being replaced, if any, is closed: no message would ever be sent to it + * again, and leaving it open would leak the underlying connection. * @param transport The dedicated SSE transport stream * @return a stream representation */ public McpStreamableServerSessionStream listeningStream(McpStreamableServerTransport transport) { McpStreamableServerSessionStream listeningStream = new McpStreamableServerSessionStream(transport); - this.listeningStreamRef.set(listeningStream); + McpLoggableSession replaced = this.listeningStreamRef.getAndSet(listeningStream); + if (replaced instanceof McpStreamableServerSessionStream replacedStream) { + logger.debug("Releasing the connection of the listening stream replaced in session {}", this.id); + replacedStream.releaseTransport(); + } return listeningStream; } + /** + * Releases the connection of the listening stream, if one is attached, leaving the + * session without one until the client establishes a new stream. Used when the + * connection turns out to be dead, typically because a keep-alive ping went + * unanswered, so that the socket behind it is not held on to for nothing. + */ + public void releaseListeningStream() { + if (this.listeningStreamRef.get() instanceof McpStreamableServerSessionStream stream) { + stream.releaseTransport(); + } + } + // TODO: keep track of history by keeping a map from eventId to stream and then // iterate over the events using the lastEventId public Flux replay(Object lastEventId) { return Flux.empty(); } + /** + * Create a response stream (the SSE stream of a single HTTP POST request, finalized + * with the response to the request it carries). The caller owns the returned stream + * and is responsible for releasing it once the connection behind it ends, the same + * way it does for a {@link #listeningStream(McpStreamableServerTransport)}: a stream + * outliving its connection keeps the session looking busy, see + * {@link #hasOpenStream()}. + * @param transport the SSE transport stream to send messages to + * @return a stream representation, on which + * {@link McpStreamableServerSessionStream#handle(McpSchema.JSONRPCRequest)} runs the + * request + */ + public McpStreamableServerSessionStream responseStream(McpStreamableServerTransport transport) { + return new McpStreamableServerSessionStream(transport); + } + /** * Provide the SSE stream of MCP messages finalized with a Response. * @param jsonrpcRequest the MCP request triggering the stream creation * @param transport the SSE transport stream to send messages to * @return Mono which completes once the processing is done + * @deprecated the stream created for the request is not exposed, which leaves the + * caller unable to release it when the connection carrying it ends. Use + * {@link #responseStream(McpStreamableServerTransport)} and + * {@link McpStreamableServerSessionStream#handle(McpSchema.JSONRPCRequest)} instead. */ + @Deprecated public Mono responseStream(McpSchema.JSONRPCRequest jsonrpcRequest, McpStreamableServerTransport transport) { - return Mono.deferContextual(ctx -> { - McpTransportContext transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); - - McpStreamableServerSessionStream stream = new McpStreamableServerSessionStream(transport); - McpRequestHandler requestHandler = McpStreamableServerSession.this.requestHandlers - .get(jsonrpcRequest.method()); - // TODO: delegate to stream, which upon successful response should close - // remove itself from the registry and also close the underlying transport - // (sink) - if (requestHandler == null) { - MethodNotFoundError error = getMethodNotFoundError(jsonrpcRequest.method()); - return transport - .sendMessage( - McpSchema.JSONRPCResponse - .error(jsonrpcRequest.id(), - new McpSchema.JSONRPCResponse.JSONRPCError( - McpSchema.ErrorCodes.METHOD_NOT_FOUND, error.message(), error.data()))) - .then(transport.closeGracefully()); - } - return requestHandler - .handle(new McpAsyncServerExchange(this.id, stream, clientCapabilities.get(), clientInfo.get(), - transportContext, this.jsonSchemaValidator), jsonrpcRequest.params()) - .map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result)) - .onErrorResume(e -> { - McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError - && mcpError.getJsonRpcError() != null) ? mcpError.getJsonRpcError() - : new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, - e.getMessage(), McpError.aggregateExceptionMessages(e)); - - var errorResponse = McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), jsonRpcError); - return Mono.just(errorResponse); - }) - .flatMap(transport::sendMessage) - .then(transport.closeGracefully()); - }); + return Mono.defer(() -> this.responseStream(transport).handle(jsonrpcRequest)); } /** @@ -271,22 +313,13 @@ public class McpStreamableServerSession implements McpLoggableSession { logger.debug("Received response: {}", response); if (response.id() != null) { - var stream = this.requestIdToStream.get(response.id()); - if (stream == null) { + var pendingRequest = this.pendingRequests.remove(response.id()); + if (pendingRequest == null) { return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR) .message("Unexpected response for unknown id " + response.id()) .build()); } - // TODO: encapsulate this inside the stream itself - var sink = stream.pendingResponses.remove(response.id()); - if (sink == null) { - return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR) - .message("Unexpected response for unknown id " + response.id()) - .build()); - } - else { - sink.success(response); - } + pendingRequest.sink().success(response); } else { logger.error("Discarded MCP request response without session id. " @@ -304,23 +337,49 @@ public class McpStreamableServerSession implements McpLoggableSession { return new MethodNotFoundError(method, "Method not found: " + method, null); } + /** + * Fails the matching pending requests with the given reason. Each request is removed + * before its sink is failed, so that the cleanup the failure triggers on the + * requesting side finds nothing left to remove. + * @param match selects the requests to fail + * @param reason the message of the error the sinks are failed with + */ + private void failPendingRequests(Predicate match, String reason) { + List requestIds = new ArrayList<>(); + this.pendingRequests.forEach((requestId, pendingRequest) -> { + if (match.test(pendingRequest)) { + requestIds.add(requestId); + } + }); + for (String requestId : requestIds) { + PendingRequest pendingRequest = this.pendingRequests.remove(requestId); + if (pendingRequest != null) { + pendingRequest.sink().error(new RuntimeException(reason)); + } + } + } + @Override public Mono closeGracefully() { return this.onClose.get().onErrorComplete().then(Mono.defer(() -> { - McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession); - return listeningStream.closeGracefully(); - // TODO: Also close all the open streams + this.listeningStreamRef.set(this.missingMcpTransportSession); + return Flux.fromIterable(List.copyOf(this.openStreams)) + .flatMap(McpStreamableServerSessionStream::closeGracefully) + .then() + // Also the requests of the streams already released: the session ending + // is the point at which no client can answer them anymore + .then(Mono.fromRunnable(() -> this.failPendingRequests(request -> true, "Session closed"))); })); } @Override public void close() { this.onClose.get().onErrorComplete().subscribe(); - McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession); - if (listeningStream != null) { - listeningStream.close(); - } - // TODO: Also close all open streams + this.listeningStreamRef.set(this.missingMcpTransportSession); + List.copyOf(this.openStreams).forEach(McpStreamableServerSessionStream::close); + // Also the requests of the streams already released: the session ending is the + // point at which no client can answer them anymore + this.failPendingRequests(request -> true, "Session closed"); } /** @@ -362,15 +421,23 @@ public class McpStreamableServerSession implements McpLoggableSession { Mono initResult) { } + /** + * A server-initiated request awaiting its response, along with the stream it was sent + * on. + * + * @param stream the stream the request was sent on + * @param sink the sink to resolve once the response arrives + */ + private record PendingRequest(McpStreamableServerSessionStream stream, MonoSink sink) { + } + /** * An individual SSE stream within a Streamable HTTP context. Can be either the * listening GET SSE stream or a request-specific POST SSE stream. */ public final class McpStreamableServerSessionStream implements McpLoggableSession { - private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>(); - - private final McpStreamableServerTransport transport; + private final McpStreamableServerTransport connection; private final String transportId; @@ -378,10 +445,11 @@ public class McpStreamableServerSession implements McpLoggableSession { /** * Constructor accepting the dedicated transport representing the SSE stream. - * @param transport request-specific SSE transport stream + * @param connection request-specific SSE transport stream */ - public McpStreamableServerSessionStream(McpStreamableServerTransport transport) { - this.transport = transport; + public McpStreamableServerSessionStream(McpStreamableServerTransport connection) { + this.connection = connection; + McpStreamableServerSession.this.openStreams.add(this); this.transportId = UUID.randomUUID().toString(); // This ID design allows for a constant-time extraction of the history by // precisely identifying the SSE stream using the first component @@ -403,19 +471,20 @@ public class McpStreamableServerSession implements McpLoggableSession { public Mono sendRequest(String method, Object requestParams, TypeRef typeRef) { String requestId = McpStreamableServerSession.this.generateRequestId(); - McpStreamableServerSession.this.requestIdToStream.put(requestId, this); - return Mono.create(sink -> { - this.pendingResponses.put(requestId, sink); + // Registered on subscription rather than on assembly, so that a request + // which is never sent does not leave the session tracking it forever + McpStreamableServerSession.this.pendingRequests.put(requestId, new PendingRequest(this, sink)); McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(method, requestId, requestParams); String messageId = this.uuidGenerator.get(); // TODO: store message in history - this.transport.sendMessage(jsonrpcRequest, messageId).subscribe(v -> { + this.connection.sendMessage(jsonrpcRequest, messageId).subscribe(v -> { }, sink::error); - }).timeout(requestTimeout).doOnError(e -> { - this.pendingResponses.remove(requestId); - McpStreamableServerSession.this.requestIdToStream.remove(requestId); + }).timeout(requestTimeout).doFinally(signal -> { + // Also on completion and cancellation: a resolved request keeps no state, + // and a deadline imposed by the caller cancels rather than errors + McpStreamableServerSession.this.pendingRequests.remove(requestId); }).handle((jsonRpcResponse, sink) -> { if (jsonRpcResponse.error() != null) { sink.error(new McpError(jsonRpcResponse.error())); @@ -425,42 +494,104 @@ public class McpStreamableServerSession implements McpLoggableSession { sink.complete(); } else { - sink.next(this.transport.unmarshalFrom(jsonRpcResponse.result(), typeRef)); + sink.next(this.connection.unmarshalFrom(jsonRpcResponse.result(), typeRef)); } } }); } + /** + * Runs the request this stream was created for, sending its messages to the + * client and finalizing the stream with the response. + *

    + * The stream is detached from the session once the request is done with it, + * whatever the outcome: a response, an error, or the caller giving up. + * @param jsonrpcRequest the MCP request this stream carries the response of + * @return Mono which completes once the processing is done + */ + public Mono handle(McpSchema.JSONRPCRequest jsonrpcRequest) { + // The stream is released whichever way the request ends: with a response, on + // an error, or by the caller giving up on it + return Mono.usingWhen(Mono.just(this), stream -> Mono.deferContextual(ctx -> { + McpTransportContext transportContext = ctx.getOrDefault(McpTransportContext.KEY, + McpTransportContext.EMPTY); + + McpRequestHandler requestHandler = McpStreamableServerSession.this.requestHandlers + .get(jsonrpcRequest.method()); + if (requestHandler == null) { + MethodNotFoundError error = getMethodNotFoundError(jsonrpcRequest.method()); + return this.connection.sendMessage(McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), + new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND, + error.message(), error.data()))); + } + return requestHandler + .handle(new McpAsyncServerExchange(McpStreamableServerSession.this.id, this, + clientCapabilities.get(), clientInfo.get(), transportContext, + McpStreamableServerSession.this.jsonSchemaValidator), jsonrpcRequest.params()) + .map(result -> McpSchema.JSONRPCResponse.result(jsonrpcRequest.id(), result)) + .onErrorResume(e -> { + McpSchema.JSONRPCResponse.JSONRPCError jsonRpcError = (e instanceof McpError mcpError + && mcpError.getJsonRpcError() != null) + ? mcpError.getJsonRpcError() + : new McpSchema.JSONRPCResponse.JSONRPCError( + McpSchema.ErrorCodes.INTERNAL_ERROR, e.getMessage(), + McpError.aggregateExceptionMessages(e)); + + var errorResponse = McpSchema.JSONRPCResponse.error(jsonrpcRequest.id(), jsonRpcError); + return Mono.just(errorResponse); + }) + .flatMap(this.connection::sendMessage); + }), McpStreamableServerSessionStream::closeGracefully); + } + @Override public Mono sendNotification(String method, Object params) { McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(method, params); String messageId = this.uuidGenerator.get(); // TODO: store message in history - return this.transport.sendMessage(jsonrpcNotification, messageId); + return this.connection.sendMessage(jsonrpcNotification, messageId); } @Override public Mono closeGracefully() { return Mono.defer(() -> { - this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed"))); - this.pendingResponses.clear(); + McpStreamableServerSession.this.openStreams.remove(this); + McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()), + "Stream closed"); // If this was the generic stream, reset it McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this, McpStreamableServerSession.this.missingMcpTransportSession); - McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals); - return this.transport.closeGracefully(); + return this.connection.closeGracefully(); }); } @Override public void close() { - this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed"))); - this.pendingResponses.clear(); + McpStreamableServerSession.this.openStreams.remove(this); + McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()), + "Stream closed"); // If this was the generic stream, reset it McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this, McpStreamableServerSession.this.missingMcpTransportSession); - McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals); - this.transport.close(); + this.connection.close(); + } + + /** + * Releases the connection carrying this stream, detaching the stream from the + * session, but keeps its pending server-initiated requests resolvable: the client + * answers those with a separate HTTP POST request, which outlives the SSE stream + * the request was sent on. + *

    + * This is the counterpart of {@link #close()} for the end of a connection rather + * than the end of the session: an SSE stream going away, whether replaced, + * disconnected or timed out, does not invalidate the requests sent on it. + */ + public void releaseTransport() { + McpStreamableServerSession.this.openStreams.remove(this); + // If this was the generic stream, reset it + McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this, + McpStreamableServerSession.this.missingMcpTransportSession); + this.connection.close(); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java index 1fa6b30..7218be5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java @@ -31,6 +31,24 @@ public final class Assert { } } + /** + * Assert that the collection contains no {@code null} elements. A {@code null} or + * empty collection passes. + * @param collection the collection to check + * @param message the exception message to use if the assertion fails + * @throws IllegalArgumentException if the collection contains a {@code null} element + */ + public static void noNullElements(@Nullable Collection collection, String message) { + if (collection == null) { + return; + } + for (Object element : collection) { + if (element == null) { + throw new IllegalArgumentException(message); + } + } + } + /** * Assert that an object is not {@code null}. * diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java index 6d53ed5..0199262 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java @@ -1,11 +1,12 @@ /** - * Copyright 2025 - 2025 the original author or authors. + * Copyright 2025 - 2026 the original author or authors. */ package io.modelcontextprotocol.util; import java.time.Duration; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import java.util.function.Supplier; import org.slf4j.Logger; @@ -28,6 +29,7 @@ import reactor.core.scheduler.Schedulers; * The pings are sent to all active mcp sessions at regular intervals. * * @author Christian Tzolov + * @author Daniel Garnier-Moiroux */ public class KeepAliveScheduler { @@ -57,6 +59,9 @@ public class KeepAliveScheduler { /** Supplier for reactive McpSession instances */ private final Supplier> mcpSessions; + /** Invoked with the session whose keep-alive ping went unanswered */ + private final Consumer onPingFailure; + /** * Creates a KeepAliveScheduler with a custom scheduler, initial delay, interval and a * supplier for McpSession instances. @@ -64,13 +69,15 @@ public class KeepAliveScheduler { * @param initialDelay Initial delay before the first keepAlive call * @param interval Interval between subsequent keepAlive calls * @param mcpSessions Supplier for McpSession instances + * @param onPingFailure Callback invoked with the session whose ping went unanswered */ KeepAliveScheduler(Scheduler scheduler, Duration initialDelay, Duration interval, - Supplier> mcpSessions) { + Supplier> mcpSessions, Consumer onPingFailure) { this.scheduler = scheduler; this.initialDelay = initialDelay; this.interval = interval; this.mcpSessions = mcpSessions; + this.onPingFailure = onPingFailure; } /** @@ -92,8 +99,14 @@ public class KeepAliveScheduler { .doOnNext(tick -> { this.mcpSessions.get() .flatMap(session -> session.sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF) - .doOnError(e -> logger.warn("Failed to send keep-alive ping to session {}: {}", session, - e.getMessage())) + // A ping has to be answered before the next one is due. The + // request timeout of the session is unrelated to keeping the + // connection alive, and is measured in hours by default. + .timeout(this.interval) + .doOnError(e -> { + logger.warn("Keep-alive ping to session {} failed: {}", session, e.getMessage()); + this.onPingFailure.accept(session); + }) .onErrorComplete()) .subscribe(); }) @@ -148,12 +161,15 @@ public class KeepAliveScheduler { private Scheduler scheduler = Schedulers.boundedElastic(); - private Duration initialDelay = Duration.ofSeconds(0); + private Duration initialDelay = Duration.ofSeconds(30); private Duration interval = Duration.ofSeconds(30); private Supplier> mcpSessions; + private Consumer onPingFailure = session -> { + }; + /** * Creates a new Builder instance with a supplier for McpSession instances. * @param mcpSessions The supplier for McpSession instances @@ -204,12 +220,28 @@ public class KeepAliveScheduler { return this; } + /** + * Sets the callback invoked when a session does not answer a keep-alive ping + * within the keep-alive interval. An unanswered ping means the connection the + * ping was written to is dead, which the operating system does not necessarily + * report: writing to a connection whose peer is gone keeps succeeding until it + * resets. It does not mean the session itself is over, as the client is free to + * reconnect to it. + * @param onPingFailure The callback receiving the unresponsive session + * @return This builder instance for method chaining + */ + public Builder onPingFailure(Consumer onPingFailure) { + Assert.notNull(onPingFailure, "onPingFailure must not be null"); + this.onPingFailure = onPingFailure; + return this; + } + /** * Builds and returns a new KeepAliveScheduler instance. * @return A new KeepAliveScheduler configured with the builder's settings */ public KeepAliveScheduler build() { - return new KeepAliveScheduler(scheduler, initialDelay, interval, mcpSessions); + return new KeepAliveScheduler(scheduler, initialDelay, interval, mcpSessions, onPingFailure); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncListFilterTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncListFilterTests.java new file mode 100644 index 0000000..91458f3 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncListFilterTests.java @@ -0,0 +1,192 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema.Tool; +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.assertThatIllegalArgumentException; + +class McpAsyncListFilterTests { + + private static final Tool TOOL = Tool.builder("tool1", EMPTY_JSON_SCHEMA).build(); + + private static final McpTransportContext CONTEXT = McpTransportContext.create(Map.of("role", "admin")); + + private static McpAsyncListFilter constant(boolean visible) { + return (context, tool) -> Mono.just(visible); + } + + @Test + void fromSyncRejectsNullFilter() { + assertThatIllegalArgumentException().isThrownBy(() -> McpAsyncListFilter.fromSync(null, false)) + .withMessage("filter must not be null"); + assertThatIllegalArgumentException().isThrownBy(() -> McpAsyncListFilter.fromSync(null, true)) + .withMessage("filter must not be null"); + } + + @Test + void fromSyncPassesThroughContextAndPrimitive() { + var seenContext = new AtomicReference(); + var seenTool = new AtomicReference(); + + McpSyncListFilter filter = (context, tool) -> { + seenContext.set(context); + seenTool.set(tool); + return true; + }; + + StepVerifier.create(McpAsyncListFilter.fromSync(filter, true).isVisible(CONTEXT, TOOL)) + .expectNext(true) + .verifyComplete(); + + assertThat(seenContext.get()).isSameAs(CONTEXT); + assertThat(seenTool.get()).isSameAs(TOOL); + } + + @Test + void fromSyncOffloadsToBoundedElasticByDefaultSoABlockingFilterCannotStallTheTransport() { + var thread = new AtomicReference(); + McpSyncListFilter filter = (context, tool) -> { + thread.set(Thread.currentThread().getName()); + return false; + }; + + StepVerifier.create(McpAsyncListFilter.fromSync(filter, false).isVisible(CONTEXT, TOOL)) + .expectNext(false) + .verifyComplete(); + + assertThat(thread.get()).startsWith("boundedElastic-"); + } + + @Test + void fromSyncRunsInlineWithImmediateExecution() { + var thread = new AtomicReference(); + McpSyncListFilter filter = (context, tool) -> { + thread.set(Thread.currentThread().getName()); + return true; + }; + + var callingThread = Thread.currentThread().getName(); + + StepVerifier.create(McpAsyncListFilter.fromSync(filter, true).isVisible(CONTEXT, TOOL)) + .expectNext(true) + .verifyComplete(); + + assertThat(thread.get()).isEqualTo(callingThread); + } + + @Test + void fromSyncPropagatesFilterExceptions() { + McpSyncListFilter filter = (context, tool) -> { + throw new IllegalStateException("policy service unavailable"); + }; + + StepVerifier.create(McpAsyncListFilter.fromSync(filter, true).isVisible(CONTEXT, TOOL)) + .verifyErrorMessage("policy service unavailable"); + } + + @Test + void andWithoutFiltersMakesEverythingVisible() { + StepVerifier.create(McpAsyncListFilter.and(null).isVisible(CONTEXT, TOOL)) + .expectNext(true) + .verifyComplete(); + StepVerifier.create(McpAsyncListFilter.and(List.of()).isVisible(CONTEXT, TOOL)) + .expectNext(true) + .verifyComplete(); + } + + @Test + void andWithASingleFilterReturnsThatFilterUnwrapped() { + var filter = constant(false); + + assertThat(McpAsyncListFilter.and(List.of(filter))).isSameAs(filter); + } + + @Test + void andRequiresEveryFilterToAccept() { + StepVerifier.create(McpAsyncListFilter.and(List.of(constant(true), constant(true))).isVisible(CONTEXT, TOOL)) + .expectNext(true) + .verifyComplete(); + + StepVerifier.create(McpAsyncListFilter.and(List.of(constant(true), constant(false))).isVisible(CONTEXT, TOOL)) + .expectNext(false) + .verifyComplete(); + + StepVerifier.create(McpAsyncListFilter.and(List.of(constant(false), constant(true))).isVisible(CONTEXT, TOOL)) + .expectNext(false) + .verifyComplete(); + } + + @Test + void andShortCircuitsSoLaterFiltersAreNotConsultedAfterARejection() { + var consulted = new CopyOnWriteArrayList(); + McpAsyncListFilter first = (context, tool) -> { + consulted.add("first"); + return Mono.just(false); + }; + McpAsyncListFilter second = (context, tool) -> { + consulted.add("second"); + return Mono.just(true); + }; + + StepVerifier.create(McpAsyncListFilter.and(List.of(first, second)).isVisible(CONTEXT, TOOL)) + .expectNext(false) + .verifyComplete(); + + assertThat(consulted).containsExactly("first"); + } + + @Test + void andTreatsAnEmptyFilterAsHiding() { + McpAsyncListFilter undecided = (context, tool) -> Mono.empty(); + + StepVerifier.create(McpAsyncListFilter.and(List.of(constant(true), undecided)).isVisible(CONTEXT, TOOL)) + .expectNext(false) + .verifyComplete(); + } + + @Test + void andPropagatesFilterErrors() { + McpAsyncListFilter failing = (context, tool) -> Mono.error(new IllegalStateException("policy down")); + + StepVerifier.create(McpAsyncListFilter.and(List.of(constant(true), failing)).isVisible(CONTEXT, TOOL)) + .verifyErrorMessage("policy down"); + } + + @Test + void andRejectsNullFilters() { + assertThatIllegalArgumentException() + .isThrownBy(() -> McpAsyncListFilter.and(Collections.>singletonList(null))) + .withMessage("filters must not contain null elements"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> McpAsyncListFilter.and(Arrays.asList(constant(true), null))) + .withMessage("filters must not contain null elements"); + } + + @Test + void andSnapshotsTheFiltersSoLaterBuilderMutationsDoNotLeakIn() { + var filters = new java.util.ArrayList>(List.of(constant(true), constant(true))); + + var composed = McpAsyncListFilter.and(filters); + filters.add(constant(false)); + + StepVerifier.create(composed.isVisible(CONTEXT, TOOL)).expectNext(true).verifyComplete(); + } + +} 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 index d4cf858..659dfeb 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidatorTests.java @@ -43,50 +43,50 @@ class DefaultServerTransportSecurityValidatorTests { @Test void originHeaderMissing() { - assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(emptyAccessor())).doesNotThrowAnyException(); } @Test void originHeaderListEmpty() { - assertThatThrownBy(() -> validator.validateHeaders(Map.of("Origin", List.of()))).isEqualTo(INVALID_ORIGIN); + assertThatCode(() -> validator.validate(headerAccessor())).doesNotThrowAnyException(); } @Test void caseInsensitive() { - var headers = Map.of("origin", List.of("http://localhost:8080")); + var accessor = headerAccessor("Origin", "http://localhost:8080"); - assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException(); } @Test void exactMatch() { - var headers = originHeader("http://localhost:8080"); + var accessor = originAccessor("http://localhost:8080"); - assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException(); } @Test void differentPort() { - var headers = originHeader("http://localhost:3000"); + var accessor = originAccessor("http://localhost:3000"); - assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN); } @Test void differentHost() { - var headers = originHeader("http://example.com:8080"); + var accessor = originAccessor("http://example.com:8080"); - assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN); } @Test void differentScheme() { - var headers = originHeader("https://localhost:8080"); + var accessor = originAccessor("https://localhost:8080"); - assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN); } @Nested @@ -99,37 +99,37 @@ class DefaultServerTransportSecurityValidatorTests { @Test void anyPortWithWildcard() { - var headers = originHeader("http://localhost:3000"); + var accessor = originAccessor("http://localhost:3000"); - assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void noPortWithWildcard() { - var headers = originHeader("http://localhost"); + var accessor = originAccessor("http://localhost"); - assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void differentPortWithWildcard() { - var headers = originHeader("http://localhost:8080"); + var accessor = originAccessor("http://localhost:8080"); - assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void differentHostWithWildcard() { - var headers = originHeader("http://example.com:3000"); + var accessor = originAccessor("http://example.com:3000"); - assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> wildcardValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN); } @Test void differentSchemeWithWildcard() { - var headers = originHeader("https://localhost:3000"); + var accessor = originAccessor("https://localhost:3000"); - assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> wildcardValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN); } } @@ -146,23 +146,23 @@ class DefaultServerTransportSecurityValidatorTests { @Test void matchingOneOfMultiple() { - var headers = originHeader("http://example.com:3000"); + var accessor = originAccessor("http://example.com:3000"); - assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> multipleOriginsValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void matchingWildcardInMultiple() { - var headers = originHeader("http://myapp.example.com:9999"); + var accessor = originAccessor("http://myapp.example.com:9999"); - assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> multipleOriginsValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void notMatchingAny() { - var headers = originHeader("http://malicious.example.com:1234"); + var accessor = originAccessor("http://malicious.example.com:1234"); - assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> multipleOriginsValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN); } } @@ -176,9 +176,9 @@ class DefaultServerTransportSecurityValidatorTests { .allowedOrigins(List.of("http://localhost:8080", "http://example.com:*")) .build(); - var headers = originHeader("http://example.com:3000"); + var accessor = originAccessor("http://example.com:3000"); - assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException(); } @Test @@ -188,11 +188,11 @@ class DefaultServerTransportSecurityValidatorTests { .allowedOrigins(List.of("http://example.com:*", "http://test.com:3000")) .build(); - assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080"))) + assertThatCode(() -> validator.validate(originAccessor("http://localhost:8080"))) .doesNotThrowAnyException(); - assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999"))) + assertThatCode(() -> validator.validate(originAccessor("http://example.com:9999"))) .doesNotThrowAnyException(); - assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000"))) + assertThatCode(() -> validator.validate(originAccessor("http://test.com:3000"))) .doesNotThrowAnyException(); } @@ -210,45 +210,45 @@ class DefaultServerTransportSecurityValidatorTests { @Test void notConfigured() { - assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(emptyAccessor())).doesNotThrowAnyException(); } @Test void missing() { - assertThatThrownBy(() -> hostValidator.validateHeaders(new HashMap<>())).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> hostValidator.validate(emptyAccessor())).isEqualTo(INVALID_HOST); } @Test void listEmpty() { - assertThatThrownBy(() -> hostValidator.validateHeaders(Map.of("Host", List.of()))).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> hostValidator.validate(headerAccessor())).isEqualTo(INVALID_HOST); } @Test void caseInsensitive() { - var headers = Map.of("host", List.of("localhost:8080")); + var accessor = headerAccessor("Host", "localhost:8080"); - assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> hostValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void exactMatch() { - var headers = hostHeader("localhost:8080"); + var accessor = hostAccessor("localhost:8080"); - assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> hostValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void differentPort() { - var headers = hostHeader("localhost:3000"); + var accessor = hostAccessor("localhost:3000"); - assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> hostValidator.validate(accessor)).isEqualTo(INVALID_HOST); } @Test void differentHost() { - var headers = hostHeader("example.com:8080"); + var accessor = hostAccessor("example.com:8080"); - assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> hostValidator.validate(accessor)).isEqualTo(INVALID_HOST); } @Nested @@ -261,23 +261,23 @@ class DefaultServerTransportSecurityValidatorTests { @Test void anyPort() { - var headers = hostHeader("localhost:3000"); + var accessor = hostAccessor("localhost:3000"); - assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void noPort() { - var headers = hostHeader("localhost"); + var accessor = hostAccessor("localhost"); - assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void differentHost() { - var headers = hostHeader("example.com:3000"); + var accessor = hostAccessor("example.com:3000"); - assertThatThrownBy(() -> wildcardHostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> wildcardHostValidator.validate(accessor)).isEqualTo(INVALID_HOST); } } @@ -293,30 +293,30 @@ class DefaultServerTransportSecurityValidatorTests { @Test void exactMatch() { - var headers = hostHeader("example.com:3000"); + var accessor = hostAccessor("example.com:3000"); - assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> multipleHostsValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void wildcard() { - var headers = hostHeader("myapp.example.com:9999"); + var accessor = hostAccessor("myapp.example.com:9999"); - assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); + assertThatCode(() -> multipleHostsValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void differentHost() { - var headers = hostHeader("malicious.example.com:3000"); + var accessor = hostAccessor("malicious.example.com:3000"); - assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> multipleHostsValidator.validate(accessor)).isEqualTo(INVALID_HOST); } @Test void differentPort() { - var headers = hostHeader("localhost:8080"); + var accessor = hostAccessor("localhost:8080"); - assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> multipleHostsValidator.validate(accessor)).isEqualTo(INVALID_HOST); } } @@ -330,10 +330,8 @@ class DefaultServerTransportSecurityValidatorTests { .allowedHosts(List.of("localhost:8080", "example.com:*")) .build(); - assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:3000"))) - .doesNotThrowAnyException(); - assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080"))) - .doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(hostAccessor("example.com:3000"))).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(hostAccessor("localhost:8080"))).doesNotThrowAnyException(); } @Test @@ -343,11 +341,9 @@ class DefaultServerTransportSecurityValidatorTests { .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(); + assertThatCode(() -> validator.validate(hostAccessor("localhost:8080"))).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(hostAccessor("example.com:9999"))).doesNotThrowAnyException(); + assertThatCode(() -> validator.validate(hostAccessor("test.com:3000"))).doesNotThrowAnyException(); } } @@ -365,60 +361,195 @@ class DefaultServerTransportSecurityValidatorTests { @Test void bothValid() { - var header = headers("http://localhost:8080", "localhost:8080"); + var accessor = combinedAccessor("http://localhost:8080", "localhost:8080"); - assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); + assertThatCode(() -> combinedValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void originValidHostInvalid() { - var header = headers("http://localhost:8080", "malicious.example.com:8080"); + var accessor = combinedAccessor("http://localhost:8080", "malicious.example.com:8080"); - assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_HOST); } @Test void originInvalidHostValid() { - var header = headers("http://malicious.example.com:8080", "localhost:8080"); + var accessor = combinedAccessor("http://malicious.example.com:8080", "localhost:8080"); - assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN); } @Test void originMissingHostValid() { // Origin missing is OK (same-origin request) - var header = headers(null, "localhost:8080"); + var accessor = combinedAccessor(null, "localhost:8080"); - assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); + assertThatCode(() -> combinedValidator.validate(accessor)).doesNotThrowAnyException(); } @Test void originValidHostMissing() { // Host missing is NOT OK when allowedHosts is configured - var header = headers("http://localhost:8080", null); + var accessor = combinedAccessor("http://localhost:8080", null); - assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_HOST); } } - private static Map> originHeader(String origin) { - return Map.of("Origin", List.of(origin)); + @Nested + class DeprecatedMapBasedApi { + + @Test + void originValidation() { + Map> headers = new HashMap<>(); + headers.put("Origin", List.of("http://localhost:8080")); + + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void originRejected() { + Map> headers = new HashMap<>(); + headers.put("Origin", List.of("http://malicious.example.com")); + + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); + } + + @Test + void caseInsensitiveHeaderLookup() { + Map> headers = new HashMap<>(); + headers.put("origin", List.of("http://localhost:8080")); + + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void hostValidation() { + DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator.builder() + .allowedHost("localhost:8080") + .build(); + + Map> headers = new HashMap<>(); + headers.put("Host", List.of("localhost:8080")); + + assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + + @Test + void hostRejected() { + DefaultServerTransportSecurityValidator hostValidator = DefaultServerTransportSecurityValidator.builder() + .allowedHost("localhost:8080") + .build(); + + Map> headers = new HashMap<>(); + headers.put("Host", List.of("malicious.com:8080")); + + assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); + } + + @Test + void emptyHeaders() { + assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); + } + + @Test + void combinedOriginAndHost() { + DefaultServerTransportSecurityValidator combinedValidator = DefaultServerTransportSecurityValidator + .builder() + .allowedOrigin("http://localhost:*") + .allowedHost("localhost:*") + .build(); + + Map> headers = new HashMap<>(); + headers.put("Origin", List.of("http://localhost:8080")); + headers.put("Host", List.of("localhost:8080")); + + assertThatCode(() -> combinedValidator.validateHeaders(headers)).doesNotThrowAnyException(); + } + } - private static Map> hostHeader(String host) { - return Map.of("Host", List.of(host)); + @Nested + class ValidatorCompatibility { + + @Test + void noopAcceptsAll() { + assertThatCode(() -> ServerHttpHeaderValidator.NOOP.validate(emptyAccessor())).doesNotThrowAnyException(); + assertThatCode(() -> ServerTransportSecurityValidator.NOOP.validateHeaders(new HashMap<>())) + .doesNotThrowAnyException(); + } + + @Test + void legacyValidatorAdaptsToHeaderAccessor() { + ServerTransportSecurityValidator legacyValidator = headers -> { + List origins = headers.getOrDefault("Origin", List.of()); + if (!origins.isEmpty() && origins.get(0).contains("evil")) { + throw new ServerTransportSecurityException(403, "Invalid Origin header"); + } + }; + ServerHttpHeaderValidator headerValidator = ServerTransportSecurityValidator + .toHttpHeaderValidator(legacyValidator); + + assertThatCode(() -> headerValidator.validate(originAccessor("http://good.example.com"))) + .doesNotThrowAnyException(); + + assertThatThrownBy(() -> headerValidator.validate(originAccessor("http://evil.example.com"))) + .isEqualTo(INVALID_ORIGIN); + } + } - private static Map> headers(String origin, String host) { - var map = new HashMap>(); + private static HeaderAccessor emptyAccessor() { + return headerAccessor(); + } + + private static HeaderAccessor headerAccessor(String... namesAndValues) { + Map> headers = new HashMap<>(); + for (int i = 0; i < namesAndValues.length; i += 2) { + headers.put(namesAndValues[i], List.of(namesAndValues[i + 1])); + } + return new HeaderAccessor() { + @Override + public List getHeader(String name) { + return headers.getOrDefault(name, List.of()); + } + + @Override + public List getHeaderNames() { + return List.copyOf(headers.keySet()); + } + }; + } + + private static HeaderAccessor originAccessor(String origin) { + return headerAccessor("Origin", origin); + } + + private static HeaderAccessor hostAccessor(String host) { + return headerAccessor("Host", host); + } + + private static HeaderAccessor combinedAccessor(String origin, String host) { + Map> headers = new HashMap<>(); if (origin != null) { - map.put("Origin", List.of(origin)); + headers.put("Origin", List.of(origin)); } if (host != null) { - map.put("Host", List.of(host)); + headers.put("Host", List.of(host)); } - return map; + return new HeaderAccessor() { + @Override + public List getHeader(String name) { + return headers.getOrDefault(name, List.of()); + } + + @Override + public List getHeaderNames() { + return List.copyOf(headers.keySet()); + } + }; } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpStreamableServerSessionTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpStreamableServerSessionTests.java new file mode 100644 index 0000000..f53be41 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpStreamableServerSessionTests.java @@ -0,0 +1,256 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import java.time.Duration; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.server.McpRequestHandler; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link McpStreamableServerSession}. + */ +class McpStreamableServerSessionTests { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private McpStreamableServerSession session() { + return session(Map.of()); + } + + private McpStreamableServerSession session(Map> requestHandlers) { + return new McpStreamableServerSession("session-1", McpSchema.ClientCapabilities.builder().build(), + new McpSchema.Implementation("test-client", "1.0.0"), TIMEOUT, requestHandlers, Map.of()); + } + + @Test + void replacedListeningStreamHasItsConnectionReleased() { + var session = session(); + var firstTransport = new RecordingTransport(); + + session.listeningStream(firstTransport); + assertThat(firstTransport.closed).isFalse(); + + session.listeningStream(new RecordingTransport()); + assertThat(firstTransport.closed).isTrue(); + } + + @Test + void replacingListeningStreamKeepsItsPendingRequestsResolvable() { + var session = session(); + var firstTransport = new RecordingTransport(); + session.listeningStream(firstTransport); + + // Server-initiated requests (sampling, elicitation, roots/list) are sent on the + // listening SSE stream, but the client answers them with a separate HTTP POST + // which outlives that stream. + var pending = session.sendRequest("sampling/createMessage", null, new TypeRef() { + }).toFuture(); + assertThat(firstTransport.sent).hasSize(1); + var requestId = ((McpSchema.JSONRPCRequest) firstTransport.sent.peek()).id(); + + // The client reconnects with a Last-Event-ID header, replacing the listening + // stream. The request sent on the replaced stream must stay pending. + session.listeningStream(new RecordingTransport()); + assertThat(pending).isNotDone(); + + session.accept(McpSchema.JSONRPCResponse.result(requestId, "response-value")).block(TIMEOUT); + + assertThat(pending).succeedsWithin(TIMEOUT).isEqualTo("response-value"); + } + + @Test + void closingTheSessionReleasesTheConnectionOfItsStreams() { + var session = session(); + var transport = new RecordingTransport(); + session.listeningStream(transport); + + session.close(); + + assertThat(transport.closed).isTrue(); + } + + @Test + void closingTheSessionFailsThePendingRequestsOfReleasedStreams() { + var session = session(); + session.listeningStream(new RecordingTransport()); + + var pending = session.sendRequest("sampling/createMessage", null, new TypeRef() { + }).toFuture(); + + // The stream the request was sent on is released, which leaves the request + // pending: a reconnecting client can still answer it over a separate POST + session.releaseListeningStream(); + assertThat(pending).isNotDone(); + + // The client cannot respond on a closed session, the pending request must be + // failed. + session.close(); + + assertThat(pending).failsWithin(TIMEOUT).withThrowableThat().havingCause().withMessage("Session closed"); + } + + @Test + void closingTheSessionGracefullyFailsThePendingRequestsOfReleasedStreams() { + var session = session(); + session.listeningStream(new RecordingTransport()); + + var pending = session.sendRequest("sampling/createMessage", null, new TypeRef() { + }).toFuture(); + + session.releaseListeningStream(); + assertThat(pending).isNotDone(); + + session.closeGracefully().block(TIMEOUT); + + assertThat(pending).failsWithin(TIMEOUT).withThrowableThat().havingCause().withMessage("Session closed"); + } + + @Test + void closingAStreamFailsOnlyItsOwnPendingRequests() { + var session = session(); + var listeningTransport = new RecordingTransport(); + session.listeningStream(listeningTransport); + + var onListeningStream = session.sendRequest("sampling/createMessage", null, new TypeRef() { + }).toFuture(); + + // A POST response stream carries its own server-initiated requests, which can be + // closed independently + var responseTransport = new RecordingTransport(); + var responseStream = session.new McpStreamableServerSessionStream(responseTransport); + var onResponseStream = responseStream.sendRequest("elicitation/create", null, new TypeRef() { + }).toFuture(); + + responseStream.close(); + + assertThat(onResponseStream).failsWithin(TIMEOUT) + .withThrowableThat() + .havingCause() + .withMessage("Stream closed"); + assertThat(onListeningStream).isNotDone(); + + var requestId = ((McpSchema.JSONRPCRequest) listeningTransport.sent.peek()).id(); + session.accept(McpSchema.JSONRPCResponse.result(requestId, "response-value")).block(TIMEOUT); + assertThat(onListeningStream).succeedsWithin(TIMEOUT).isEqualTo("response-value"); + } + + @Test + void endOfTheConnectionCarryingAResponseStreamDetachesItFromTheSession() { + // A request whose handler never completes, as seen when a client gives up and + // disconnects while the server is still working on its tool call + var session = session(Map.of("tools/call", (exchange, params) -> Mono.never())); + var transport = new RecordingTransport(); + + // The caller owns the stream, so it can detach it from the session once the + // container tells it the connection carrying it is gone + var stream = session.responseStream(transport); + stream.handle(new McpSchema.JSONRPCRequest("tools/call", "request-1")).subscribe(); + assertThat(session.hasOpenStream()).isTrue(); + + stream.releaseTransport(); + + // The session must stop believing it holds a live connection: hasOpenStream() is + // what tells the session sweeper that a client is still around, so a stream which + // outlives its connection makes the session impossible to reclaim + assertThat(session.hasOpenStream()).isFalse(); + assertThat(transport.closed).isTrue(); + } + + @Test + void responseStreamIsDetachedFromTheSessionOnceItsRequestIsAnswered() { + var session = session(Map.of("tools/call", (exchange, params) -> Mono.just("result"))); + var transport = new RecordingTransport(); + + var stream = session.responseStream(transport); + assertThat(session.hasOpenStream()).isTrue(); + + stream.handle(new McpSchema.JSONRPCRequest("tools/call", "request-1")).block(TIMEOUT); + + assertThat(session.hasOpenStream()).isFalse(); + } + + @Test + void responseStreamIsDetachedFromTheSessionWhenItsResponseCannotBeSent() { + var session = session(Map.of("tools/call", (exchange, params) -> Mono.just("result"))); + + var stream = session.responseStream(new FailingTransport()); + var handling = stream.handle(new McpSchema.JSONRPCRequest("tools/call", "request-1")).toFuture(); + + // The connection which was to carry the response failed. The caller gets to see + // it, and the stream must not be left attached to the session. + assertThat(handling).failsWithin(TIMEOUT).withThrowableThat().havingCause().withMessage("connection gone"); + assertThat(session.hasOpenStream()).isFalse(); + } + + @Test + void abandonedResponseStreamIsDetachedFromTheSession() { + var session = session(Map.of("tools/call", (exchange, params) -> Mono.never())); + + var stream = session.responseStream(new RecordingTransport()); + var subscription = stream.handle(new McpSchema.JSONRPCRequest("tools/call", "request-1")).subscribe(); + assertThat(session.hasOpenStream()).isTrue(); + + // The caller gives up on a request which would never terminate on its own, so + // nothing sends the response the stream was created to carry + subscription.dispose(); + + assertThat(session.hasOpenStream()).isFalse(); + } + + /** + * A transport whose connection is gone, so that nothing can be written to it. + */ + static class FailingTransport extends RecordingTransport { + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { + return Mono.error(new RuntimeException("connection gone")); + } + + } + + static class RecordingTransport implements McpStreamableServerTransport { + + final Queue sent = new ConcurrentLinkedQueue<>(); + + volatile boolean closed; + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { + return Mono.fromRunnable(() -> this.sent.add(message)); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return sendMessage(message, null); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(() -> this.closed = true); + } + + @Override + public void close() { + this.closed = true; + } + + @SuppressWarnings("unchecked") + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return (T) data; + } + + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/AssertTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/AssertTests.java index 0038d4e..baa6a22 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/util/AssertTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/AssertTests.java @@ -6,6 +6,7 @@ package io.modelcontextprotocol.util; import org.junit.jupiter.api.Test; +import java.util.Arrays; import java.util.List; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -27,6 +28,17 @@ class AssertTests { assertDoesNotThrow(() -> Assert.notEmpty(List.of("test"), "collection is not empty")); } + @Test + void testCollectionNoNullElements() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> Assert.noNullElements(Arrays.asList("test", null), "collection has null elements")); + assertEquals("collection has null elements", e.getMessage()); + + assertDoesNotThrow(() -> Assert.noNullElements(null, "collection is null")); + assertDoesNotThrow(() -> Assert.noNullElements(List.of(), "collection is empty")); + assertDoesNotThrow(() -> Assert.noNullElements(List.of("test"), "collection has no null elements")); + } + @Test void testObjectNotNull() { IllegalArgumentException e = assertThrows(IllegalArgumentException.class, diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index 5a6d0d8..5ccb32e 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.1 + 2.1.0-SNAPSHOT mcp-json-jackson2 jar @@ -20,6 +20,10 @@ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + + io.modelcontextprotocol.sdk.mcp.json.jackson2 + + @@ -37,7 +41,7 @@ Bundle-Name: Bundle ${project.groupId} : ${project.artifactId} Bundle-SymbolicName: ${project.groupId}.${project.artifactId} Bundle-Version: ${project.version} - Automatic-Module-Name: ${project.groupId}.${project.artifactId} + Automatic-Module-Name: ${module.name} Bundle-ActivationPolicy: lazy Import-Package: io.modelcontextprotocol.json,io.modelcontextprotocol.json.schema, \ *; @@ -74,7 +78,7 @@ io.modelcontextprotocol.sdk mcp-core - 2.0.1 + 2.1.0-SNAPSHOT com.networknt diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index 5eded9e..98ba82b 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.1 + 2.1.0-SNAPSHOT mcp-json-jackson3 jar @@ -20,6 +20,10 @@ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + + io.modelcontextprotocol.sdk.mcp.json.jackson3 + + @@ -37,7 +41,7 @@ Bundle-Name: Bundle ${project.groupId} : ${project.artifactId} Bundle-SymbolicName: ${project.groupId}.${project.artifactId} Bundle-Version: ${project.version} - Automatic-Module-Name: ${project.groupId}.${project.artifactId} + Automatic-Module-Name: ${module.name} Bundle-ActivationPolicy: lazy Import-Package: io.modelcontextprotocol.json,io.modelcontextprotocol.json.schema, \ *; @@ -68,7 +72,7 @@ io.modelcontextprotocol.sdk mcp-core - 2.0.1 + 2.1.0-SNAPSHOT tools.jackson.core diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index 6574e82..6002b84 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.1 + 2.1.0-SNAPSHOT mcp-test jar @@ -20,11 +20,15 @@ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + + io.modelcontextprotocol.sdk.mcp.test + + io.modelcontextprotocol.sdk mcp-core - 2.0.1 + 2.1.0-SNAPSHOT @@ -159,7 +163,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 2.0.1 + 2.1.0-SNAPSHOT test @@ -170,7 +174,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 2.0.1 + 2.1.0-SNAPSHOT test diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index f6b547a..4e85f26 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -12,6 +12,7 @@ import java.time.Duration; import java.util.HashMap; 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; @@ -49,10 +50,11 @@ 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.spec.McpTransportException; import io.modelcontextprotocol.util.Utils; import net.javacrumbs.jsonunit.core.Option; -import org.junit.jupiter.api.Test; +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 static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; @@ -72,20 +74,33 @@ public abstract class AbstractMcpClientServerIntegrationTests { protected static final int MAX_REQUEST_SIZE = 2048; + protected ConcurrentHashMap clientBuilders = new ConcurrentHashMap<>(); + + abstract protected void prepareClients(int port, String mcpEndpoint); + abstract protected McpServer.AsyncSpecification prepareAsyncServerBuilder(); abstract protected McpServer.SyncSpecification prepareSyncServerBuilder(); - abstract protected McpClient.SyncSpec getMcpClientBuilder(); + // There is, for Streamable HTTP, a race condition between establishing the SSE stream + // and the server sending notifications. This breaks some `roots/list` tests (and + // could in theory break sampling and elicitation tests). This utility method allows + // delaying the test until the stream is opened. + protected void awaitClientStreamEstablished() { + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void simple(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); - @Test - void simple() { var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") .requestTimeout(Duration.ofSeconds(1000)) .build(); try ( // Create client without sampling capabilities - var client = getMcpClientBuilder() + var client = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .requestTimeout(Duration.ofSeconds(1000)) .build()) { @@ -101,8 +116,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Sampling Tests // --------------------------------------- - @Test - void testCreateMessageWithoutSamplingCapabilities() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateMessageWithoutSamplingCapabilities(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { @@ -115,7 +134,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { try ( // Create client without sampling capabilities - var client = getMcpClientBuilder() + var client = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .build()) { @@ -134,8 +153,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateMessageSuccess() { + @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); @@ -176,7 +199,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = getMcpClientBuilder() + try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) @@ -205,8 +228,14 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateMessageWithRequestTimeoutSuccess() { + @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); @@ -256,7 +285,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .requestTimeout(Duration.ofSeconds(4)) .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder() + try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) @@ -285,8 +314,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateMessageWithRequestTimeoutFail() { + @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); @@ -331,7 +364,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder() + try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) @@ -352,8 +385,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Elicitation Tests // --------------------------------------- - @Test - void testCreateElicitationWithoutElicitationCapabilities() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationWithoutElicitationCapabilities(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> exchange.createElicitation(mock(McpSchema.ElicitFormRequest.class)) @@ -363,8 +400,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); // Create client without elicitation capabilities - try (var client = getMcpClientBuilder() - .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) + try (var client = clientBuilder.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .build()) { assertThat(client.initialize()).isNotNull(); @@ -382,8 +418,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateElicitationSuccess() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + Function formElicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(request.requestedSchema()).isNotNull(); @@ -416,7 +456,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = getMcpClientBuilder() + try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) .elicitation(formElicitationHandler) @@ -441,8 +481,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateElicitationWithApplyDefaults() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationWithApplyDefaults(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + // Client handler returns empty content — SDK should apply defaults Function elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); @@ -476,7 +520,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .applyElicitationDefaults(true) @@ -502,8 +546,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + // Client handler returns an unmodifiable map (Map.of()) — SDK must copy into a // mutable map before applying defaults. Function elicitationHandler = request -> new McpSchema.ElicitResult( @@ -534,7 +582,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .applyElicitationDefaults(true) @@ -558,8 +606,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>()); @@ -586,7 +638,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); // applyElicitationDefaults intentionally NOT called — default false. - try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .build()) { @@ -608,8 +660,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateElicitationApplyDefaultsSkippedOnDecline() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationApplyDefaultsSkippedOnDecline(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.DECLINE, new HashMap<>()); @@ -635,7 +691,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .applyElicitationDefaults(true) @@ -658,8 +714,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateElicitationApplyDefaultsPreservesMeta() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationApplyDefaultsPreservesMeta(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + Map meta = Map.of("trace-id", "abc-123"); Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>(), meta); @@ -686,7 +746,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .applyElicitationDefaults(true) @@ -710,8 +770,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateElicitationWithRequestTimeoutSuccess() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + Function elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(((McpSchema.ElicitFormRequest) request).requestedSchema()).isNotNull(); @@ -746,7 +810,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder() + try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) .elicitation(elicitationHandler) @@ -771,10 +835,14 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateElicitationWithRequestTimeoutFail() { + @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(); @@ -818,7 +886,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder() + try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) .elicitation(elicitationHandler) @@ -839,12 +907,15 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testCreateUrlElicitationSuccess() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testCreateUrlElicitationSuccess(String clientType) { var elicitationRequest = McpSchema.ElicitUrlRequest .builder("Test message", "https://example.com/auth", "elicitation-123") .build(); + var clientBuilder = clientBuilders.get(clientType); + Function urlElicitationHandler = request -> { assertThat(request.message()).isEqualTo("Test message"); assertThat(request.url()).isEqualTo("https://example.com/auth"); @@ -868,7 +939,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = getMcpClientBuilder() + try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation(false, true).build()) .urlElicitation(urlElicitationHandler) @@ -888,8 +959,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testElicitationCompleteNotification() throws InterruptedException { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testElicitationCompleteNotification(String clientType) throws InterruptedException { + + var clientBuilder = clientBuilders.get(clientType); + CountDownLatch notificationLatch = new CountDownLatch(1); AtomicReference notificationRef = new AtomicReference<>(); AtomicReference sessionId = new AtomicReference<>(); @@ -914,7 +989,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = getMcpClientBuilder() + try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .elicitationCompleteConsumer(elicitationCompleteConsumer) // enable elicitation so that we can register an elicitation complete consumer @@ -940,8 +1015,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testElicitationRequiredError() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testElicitationRequiredError(String clientType) throws InterruptedException { + + var clientBuilder = clientBuilders.get(clientType); + // Capture the session ID so we can trigger an "elicitation complete" notification McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) @@ -957,7 +1036,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { Function elicitationHandler = request -> ElicitResult .builder(ElicitResult.Action.ACCEPT) .build(); - try (var mcpClient = getMcpClientBuilder() + try (var mcpClient = clientBuilder .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .urlElicitation(elicitationHandler) .build()) { @@ -990,8 +1069,11 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Roots Tests // --------------------------------------- - @Test - void testRootsSuccess() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testRootsSuccess(String clientType) { + var clientBuilder = clientBuilders.get(clientType); + List roots = List.of(Root.builder("uri1://").name("root1").build(), Root.builder("uri2://").name("root2").build()); @@ -1001,12 +1083,13 @@ public abstract class AbstractMcpClientServerIntegrationTests { .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) .build(); - try (var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).build()) + try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) .build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); + awaitClientStreamEstablished(); assertThat(rootsRef.get()).isNull(); @@ -1036,8 +1119,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testRootsWithoutCapability() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testRootsWithoutCapability(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { @@ -1054,7 +1141,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { try ( // Create client without roots capability // No roots capability - var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().build()).build()) { + var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build()) { assertThat(mcpClient.initialize()).isNotNull(); @@ -1071,21 +1158,25 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testRootsNotificationWithEmptyRootsList() { + @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 = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).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(); - + awaitClientStreamEstablished(); mcpClient.rootsListChangedNotification(); await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { @@ -1097,8 +1188,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testRootsWithMultipleHandlers() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testRootsWithMultipleHandlers(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + List roots = List.of(Root.builder("uri1://").name("root1").build()); AtomicReference> rootsRef1 = new AtomicReference<>(); @@ -1109,12 +1204,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate)) .build(); - try (var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).build()) + try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) .build()) { assertThat(mcpClient.initialize()).isNotNull(); - + awaitClientStreamEstablished(); mcpClient.rootsListChangedNotification(); await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { @@ -1127,8 +1222,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testRootsServerCloseWithActiveSubscription() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testRootsServerCloseWithActiveSubscription(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + List roots = List.of(Root.builder("uri1://").name("root1").build()); AtomicReference> rootsRef = new AtomicReference<>(); @@ -1137,13 +1236,13 @@ public abstract class AbstractMcpClientServerIntegrationTests { .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) .build(); - try (var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).build()) + try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) .build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - + awaitClientStreamEstablished(); mcpClient.rootsListChangedNotification(); await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { @@ -1158,8 +1257,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Tools Tests // --------------------------------------- - @Test - void testToolCallSuccess() { + @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(McpSchema.TextContent.builder("CALL RESPONSE; ctx=importantValue").build()) @@ -1190,7 +1293,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool1) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1208,8 +1311,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testThrowingToolCallIsCaughtBeforeTimeout() { + @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() @@ -1222,7 +1329,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .build()) .build(); - try (var mcpClient = getMcpClientBuilder().requestTimeout(Duration.ofMillis(6666)).build()) { + try (var mcpClient = clientBuilder.requestTimeout(Duration.ofMillis(6666)).build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1237,8 +1344,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testToolCallSuccessWithTranportContextExtraction() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testToolCallSuccessWithTranportContextExtraction(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + var transportContextIsNull = new AtomicBoolean(false); var transportContextIsEmpty = new AtomicBoolean(false); var responseBodyIsNullOrBlank = new AtomicBoolean(false); @@ -1273,7 +1384,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool1) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1293,8 +1404,11 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testToolWithNonAsciiCharacters() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testToolWithNonAsciiCharacters(String clientType) { + var clientBuilder = clientBuilders.get(clientType); + String inputSchema = """ { "type": "object", @@ -1319,7 +1433,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(nonAsciiTool) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1342,8 +1456,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testToolListChangeHandlingSuccess() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testToolListChangeHandlingSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + var callResponse = McpSchema.CallToolResult.builder() .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) .build(); @@ -1375,7 +1493,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool1) .build(); - try (var mcpClient = getMcpClientBuilder().toolsChangeConsumer(toolsUpdate -> { + try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { // perform a blocking call to a remote service try { HttpResponse response = HttpClient.newHttpClient() @@ -1430,11 +1548,15 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testInitialize() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testInitialize(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + var mcpServer = prepareSyncServerBuilder().build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1447,13 +1569,16 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Logging Tests // --------------------------------------- - @Test - void testLoggingNotification() throws InterruptedException { + @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("logging-test", EMPTY_JSON_SCHEMA).description("Test logging notifications").build()) @@ -1502,7 +1627,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { try ( // Create client with logging notification handler - var mcpClient = getMcpClientBuilder().loggingConsumer(notification -> { + var mcpClient = clientBuilder.loggingConsumer(notification -> { receivedNotifications.add(notification); latch.countDown(); }).build()) { @@ -1552,14 +1677,17 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Progress Tests // --------------------------------------- - @Test - void testProgressNotification() throws InterruptedException { + @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("progress-test", EMPTY_JSON_SCHEMA) @@ -1602,7 +1730,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { try ( // Create client with progress notification handler - var mcpClient = getMcpClientBuilder().progressConsumer(notification -> { + var mcpClient = clientBuilder.progressConsumer(notification -> { receivedNotifications.add(notification); latch.countDown(); }).build()) { @@ -1663,8 +1791,11 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Completion Tests // --------------------------------------- - @Test - void testCompletionShouldReturnExpectedSuggestions() { + @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, true)); @@ -1690,7 +1821,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { McpSchema.PromptReference.builder("code_review").title("Code review").build(), completionHandler)) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1716,8 +1847,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Ping Tests // --------------------------------------- - @Test - void testPingSuccess() { + @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<>(""); @@ -1751,7 +1886,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { // Initialize client InitializeResult initResult = mcpClient.initialize(); @@ -1775,8 +1910,11 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Tool Structured Output Schema Tests // --------------------------------------- - @Test - void testStructuredOutputValidationSuccess() { + @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", @@ -1805,7 +1943,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1845,8 +1983,11 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testStructuredOutputOfObjectArrayValidationSuccess() { + @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 @@ -1877,7 +2018,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { assertThat(mcpClient.initialize()).isNotNull(); // Call tool with valid structured output of type array @@ -1901,8 +2042,11 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testStructuredOutputWithInHandlerError() { + @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", @@ -1928,7 +2072,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1954,8 +2098,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testStructuredOutputValidationFailure() { + @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", @@ -1983,7 +2131,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -2004,8 +2152,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testStructuredOutputMissingStructuredContent() { + @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")); @@ -2028,7 +2180,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -2050,14 +2202,18 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testStructuredOutputRuntimeToolAddition() { + @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 = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -2127,8 +2283,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { // Resource Subscription Tests // --------------------------------------- - @Test - void testResourceSubscription() throws InterruptedException { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testResourceSubscription(String clientType) throws InterruptedException { + + var clientBuilder = clientBuilders.get(clientType); + String resourceUri = "test://subscribable-resource"; var receivedContents = new AtomicReference>(); var latch = new CountDownLatch(1); @@ -2146,7 +2306,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .resources(resourceSpec) .build(); - try (var mcpClient = getMcpClientBuilder().resourcesUpdateConsumer(contents -> { + try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> { receivedContents.set(contents); latch.countDown(); }).build()) { @@ -2167,8 +2327,11 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @Test - void testResourceSubscription_afterUnsubscribe_noNotification() { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testResourceSubscription_afterUnsubscribe_noNotification(String clientType) throws InterruptedException { + var clientBuilder = clientBuilders.get(clientType); + String resourceUri = "test://subscribable-resource-unsub"; var notificationCount = new java.util.concurrent.atomic.AtomicInteger(0); @@ -2183,8 +2346,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .resources(resourceSpec) .build(); - try (var mcpClient = getMcpClientBuilder() - .resourcesUpdateConsumer(contents -> notificationCount.incrementAndGet()) + try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> notificationCount.incrementAndGet()) .build()) { mcpClient.initialize(); @@ -2202,8 +2364,11 @@ public abstract class AbstractMcpClientServerIntegrationTests { } // Bounded read - @Test - void testRejectsWhenContentLengthHeaderExceedsLimit() throws Exception { + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testRejectsWhenContentLengthHeaderExceedsLimit(String clientType) throws Exception { + var clientBuilder = clientBuilders.get(clientType); + String inputSchema = """ { "type": "object", @@ -2227,7 +2392,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool1) .build(); - try (var mcpClient = getMcpClientBuilder().build()) { + try (var mcpClient = clientBuilder.build()) { String oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1); mcpClient.initialize(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java index 547ccc5..1f44600 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java @@ -1,21 +1,188 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + package io.modelcontextprotocol.client; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + import io.modelcontextprotocol.client.transport.ServerParameters; +/** + * Provides the {@link ServerParameters} used to launch the {@code server-everything} MCP + * server for the stdio client tests. + * + *

    + * Those tests spawn a fresh server process per test, so the launch cost is paid dozens of + * times per build. Going through {@code npx} costs roughly 3 seconds per spawn even with + * a warm cache, because npx re-resolves the package every time, and it inserts two extra + * processes ({@code npx} → {@code npm exec} → {@code node}) between the test + * and the server. The latter also means the process the transport owns is not the server, + * so closing the transport leaves the server behind. + * + *

    + * Instead, the package is installed once per JVM into a version-keyed directory under the + * temporary directory, and the server is launched directly with {@code node}, which + * brings the per-spawn cost down to roughly 0.4 seconds. + */ public final class ServerParameterUtils { + private static final String SERVER_EVERYTHING_VERSION = "2025.12.18"; + + private static final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase().contains("win"); + + private static final Path SERVER_SCRIPT = resolveServerScript(); + + private static final String NODE_EXECUTABLE = resolveNodeExecutable(); + 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@2025.12.18", "stdio") - .build(); + return ServerParameters.builder(NODE_EXECUTABLE).args(SERVER_SCRIPT.toString(), "stdio").build(); + } + + private static Path resolveServerScript() { + Path tmpDir = Paths.get(System.getProperty("java.io.tmpdir")); + Path installDir = tmpDir.resolve("mcp-server-everything-" + SERVER_EVERYTHING_VERSION); + if (!Files.isRegularFile(serverScriptIn(installDir))) { + install(tmpDir, installDir); + } + Path script = serverScriptIn(installDir); + if (!Files.isRegularFile(script)) { + throw new IllegalStateException("server-everything was not installed at " + script); + } + return script; + } + + private static Path serverScriptIn(Path installDir) { + return installDir + .resolve(Paths.get("node_modules", "@modelcontextprotocol", "server-everything", "dist", "index.js")); + } + + /** + * Installs into a staging directory and moves it into place atomically, so that + * concurrent builds sharing the temporary directory can never observe a partially + * installed tree. + */ + private static void install(Path tmpDir, Path installDir) { + Path staging; + try { + staging = Files.createTempDirectory(tmpDir, "mcp-server-everything-staging-"); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + try { + run(npmCommand("install", "--prefix", staging.toString(), "--no-save", "--no-audit", "--no-fund", + "--loglevel=error", "@modelcontextprotocol/server-everything@" + SERVER_EVERYTHING_VERSION)); + try { + Files.move(staging, installDir, StandardCopyOption.ATOMIC_MOVE); + } + catch (IOException e) { + // Another build won the race and installed it first, which is fine as + // long as the result is usable. + if (!Files.isRegularFile(serverScriptIn(installDir))) { + throw new UncheckedIOException("Failed to install server-everything to " + installDir, e); + } + } + } + finally { + deleteRecursively(staging); + } + } + + private static void deleteRecursively(Path path) { + if (!Files.exists(path)) { + return; + } + try (Stream paths = Files.walk(path)) { + paths.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } + catch (IOException e) { + // Leftovers in the temporary directory are harmless. + } + }); + } + catch (IOException e) { + // Leftovers in the temporary directory are harmless. + } + } + + /** + * Asks node for its own absolute path, so that spawning the server does not go + * through a {@code node} wrapper script on the {@code PATH} (as installed by nvm and + * friends), which would add an extra shell process per spawn. + */ + private static String resolveNodeExecutable() { + String node = IS_WINDOWS ? "node.exe" : "node"; + try { + return run(List.of(node, "-p", "process.execPath")).trim(); + } + catch (RuntimeException e) { + return node; + } + } + + private static List npmCommand(String... args) { + List command = new ArrayList<>(); + if (IS_WINDOWS) { + command.add("cmd.exe"); + command.add("/c"); + command.add("npm.cmd"); + } + else { + command.add("npm"); + } + command.addAll(List.of(args)); + return command; + } + + private static String run(List command) { + try { + // Note: the output is redirected to a file rather than inherited, because + // writing to the native streams from a forked JVM corrupts the Surefire fork + // channel. A file rather than a pipe also keeps the timeout below effective, + // which draining the pipe first would not. + Path log = Files.createTempFile("mcp-test-", ".log"); + try { + Process process = new ProcessBuilder(command).redirectErrorStream(true) + .redirectOutput(log.toFile()) + .start(); + if (!process.waitFor(5, TimeUnit.MINUTES)) { + process.destroyForcibly(); + throw new IllegalStateException("Timed out running " + command); + } + String output = Files.readString(log); + if (process.exitValue() != 0) { + throw new IllegalStateException( + command + " failed with exit code " + process.exitValue() + ":\n" + output); + } + return output; + } + finally { + Files.deleteIfExists(log); + } + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); } - return ServerParameters.builder("npx") - .args("-y", "@modelcontextprotocol/server-everything@2025.12.18", "stdio") - .build(); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java index aa8aaa3..1dc4d99 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpAsyncClientTests.java @@ -18,16 +18,16 @@ import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; * Tests for the {@link McpAsyncClient} with {@link StdioClientTransport}. * *

    - * These tests use npx to download and run the MCP "everything" server locally. The first - * test execution will download the everything server scripts and cache them locally, - * which can take more than 15 seconds. Subsequent test runs will use the cached version - * and execute faster. + * These tests run the MCP "everything" server locally, spawning a fresh server process + * per test. The server package is installed once and launched directly with node, see + * {@link ServerParameterUtils}. The first test execution installs the package, which can + * take more than 15 seconds; subsequent runs reuse the installed copy. * * @author Christian Tzolov * @author Dariusz Jędrzejczyk */ -@Timeout(25) // Giving extra time beyond the client timeout to account for initial server - // download +@Timeout(25) // Giving extra time beyond the client timeout to account for the one-time + // install of the server package class StdioMcpAsyncClientTests extends AbstractMcpAsyncClientTests { @Override diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java index 08e5ea6..c147056 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/StdioMcpSyncClientTests.java @@ -25,16 +25,16 @@ import static org.assertj.core.api.Assertions.assertThat; * Tests for the {@link McpSyncClient} with {@link StdioClientTransport}. * *

    - * These tests use npx to download and run the MCP "everything" server locally. The first - * test execution will download the everything server scripts and cache them locally, - * which can take more than 15 seconds. Subsequent test runs will use the cached version - * and execute faster. + * These tests run the MCP "everything" server locally, spawning a fresh server process + * per test. The server package is installed once and launched directly with node, see + * {@link ServerParameterUtils}. The first test execution installs the package, which can + * take more than 15 seconds; subsequent runs reuse the installed copy. * * @author Christian Tzolov * @author Dariusz Jędrzejczyk */ -@Timeout(25) // Giving extra time beyond the client timeout to account for initial server - // download +@Timeout(25) // Giving extra time beyond the client timeout to account for the one-time + // install of the server package class StdioMcpSyncClientTests extends AbstractMcpSyncClientTests { @Override diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java index 563e520..2843231 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java @@ -17,25 +17,30 @@ import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; import io.modelcontextprotocol.server.transport.McpTestRequestRecordingServletFilter; import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.ProtocolVersions; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; -import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; class HttpClientStreamableHttpVersionNegotiationIntegrationTests { - private Tomcat tomcat; + private static Tomcat tomcat; private static final int PORT = TomcatTestUtil.findAvailablePort(); - private final McpTestRequestRecordingServletFilter requestRecordingFilter = new McpTestRequestRecordingServletFilter(); + private static final McpTestRequestRecordingServletFilter requestRecordingFilter = new McpTestRequestRecordingServletFilter(); - private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider + private static final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider .builder() .contextExtractor( req -> McpTransportContext.create(Map.of("protocol-version", req.getHeader("MCP-protocol-version")))) @@ -51,78 +56,13 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests { .isError(false) .build(); - McpSyncServer mcpServer = McpServer.sync(transport) + private final McpSyncServer mcpServer = McpServer.sync(transport) .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()) .tools(McpServerFeatures.SyncToolSpecification.builder().tool(toolSpec).callHandler(toolHandler).build()) .build(); - @AfterEach - void tearDown() { - stopTomcat(); - } - - @Test - void usesLatestVersion() { - startTomcat(); - - var client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT).build()) - .build(); - - client.initialize(); - McpSchema.CallToolResult response = client - .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); - - var calls = requestRecordingFilter.getCalls(); - - assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\"")) - // GET /mcp ; POST notification/initialized ; POST tools/call - .hasSize(3) - .map(McpTestRequestRecordingServletFilter.Call::headers) - .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", - ProtocolVersions.MCP_2025_11_25)); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo(ProtocolVersions.MCP_2025_11_25); - mcpServer.close(); - } - - @Test - void usesServerSupportedVersion() { - startTomcat(); - - var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) - .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_11_25, "2263-03-18")) - .build(); - var client = McpClient.sync(transport).build(); - - client.initialize(); - McpSchema.CallToolResult response = client - .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); - - var calls = requestRecordingFilter.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.method().equals("POST") && !c.body().contains("\"method\":\"initialize\"")) - // POST notification/initialized ; POST tools/call - .hasSize(2) - .map(McpTestRequestRecordingServletFilter.Call::headers) - .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", - ProtocolVersions.MCP_2025_11_25)); - - assertThat(response).isNotNull(); - assertThat(response.content()).hasSize(1) - .first() - .extracting(McpSchema.TextContent.class::cast) - .extracting(McpSchema.TextContent::text) - .isEqualTo(ProtocolVersions.MCP_2025_11_25); - mcpServer.close(); - } - - private void startTomcat() { + @BeforeAll + static void startTomcat() { tomcat = TomcatTestUtil.createTomcatServer("", PORT, transport, requestRecordingFilter); try { tomcat.start(); @@ -133,7 +73,8 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests { } } - private void stopTomcat() { + @AfterAll + static void stopTomcat() { if (tomcat != null) { try { tomcat.stop(); @@ -145,4 +86,92 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests { } } + @BeforeEach + void setUp() { + requestRecordingFilter.clear(); + } + + @Test + void usesLatestVersion() { + try (var client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT).build()) + .build()) { + client.initialize(); + McpSchema.CallToolResult response = client + .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); + + var calls = requestRecordingFilter.getCalls(); + + assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\"")) + // GET /mcp ; POST notification/initialized ; POST tools/call + .hasSize(3) + .map(McpTestRequestRecordingServletFilter.Call::headers) + .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", + ProtocolVersions.MCP_2025_11_25)); + + assertThat(response).isNotNull(); + assertThat(response.content()).hasSize(1) + .first() + .extracting(McpSchema.TextContent.class::cast) + .extracting(McpSchema.TextContent::text) + .isEqualTo(ProtocolVersions.MCP_2025_11_25); + } + + } + + @Test + void usesServerSupportedVersion() { + var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) + .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_11_25, "2263-03-18")) + .build(); + try (var client = McpClient.sync(transport).build()) { + client.initialize(); + McpSchema.CallToolResult response = client + .callTool(McpSchema.CallToolRequest.builder("test-tool").arguments(Map.of()).build()); + + var calls = requestRecordingFilter.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.method().equals("POST") && !c.body().contains("\"method\":\"initialize\"")) + // POST notification/initialized ; POST tools/call + .hasSize(2) + .map(McpTestRequestRecordingServletFilter.Call::headers) + .allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", + ProtocolVersions.MCP_2025_11_25)); + + assertThat(response).isNotNull(); + assertThat(response.content()).hasSize(1) + .first() + .extracting(McpSchema.TextContent.class::cast) + .extracting(McpSchema.TextContent::text) + .isEqualTo(ProtocolVersions.MCP_2025_11_25); + } + } + + @Test + void clientDoesNotSupportProtocolVersion20241105ByDefault() { + var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT).build(); + + // Testing it on the wire would require building a custom transport + // We trust the protocolVersions() accessor instead + assertThat(transport.protocolVersions()).containsExactly(ProtocolVersions.MCP_2025_03_26, + ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); + } + + @Test + void serverDoesNotSupportProtocolVersion20241105() { + var clientTransport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) + .supportedProtocolVersions(List.of(ProtocolVersions.MCP_2024_11_05)) + .build(); + try (var client = McpClient.sync(clientTransport).build()) { + assertThatThrownBy(client::initialize).rootCause() + .isInstanceOf(McpError.class) + .asInstanceOf(type(McpError.class)) + .extracting(Throwable::getMessage) + .isEqualTo("Unsupported protocol version"); + mcpServer.close(); + } + + } + } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java index c1ebe7b..c4e334f 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java @@ -27,7 +27,9 @@ import jakarta.servlet.http.HttpServletResponse; 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.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -44,14 +46,42 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message"; - private HttpServletSseServerTransportProvider mcpServerTransportProvider; + // Tomcat is started once for the whole class; each test swaps in its own transport + private static final TomcatTestUtil.DelegatingServlet MCP_SERVLET = new TomcatTestUtil.DelegatingServlet(); - private Tomcat tomcat; + private static Tomcat tomcat; + + private HttpServletSseServerTransportProvider mcpServerTransportProvider; static Stream clientsForTesting() { return Stream.of(Arguments.of("httpclient")); } + @BeforeAll + public static void beforeAll() { + tomcat = TomcatTestUtil.createTomcatServer("", PORT, MCP_SERVLET); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + @AfterAll + public static void afterAll() { + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + @BeforeEach public void before() { // Create and configure the transport provider @@ -61,15 +91,13 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT .sseEndpoint(CUSTOM_SSE_ENDPOINT) .maxRequestSize(MAX_REQUEST_SIZE) .build(); + MCP_SERVLET.setDelegate(mcpServerTransportProvider); - tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider); - try { - tomcat.start(); - assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); - } - catch (Exception e) { - throw new RuntimeException("Failed to start Tomcat", e); - } + clientBuilders + .put("httpclient", + McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT) + .sseEndpoint(CUSTOM_SSE_ENDPOINT) + .build()).requestTimeout(Duration.ofHours(10))); } @Override @@ -82,29 +110,15 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT return McpServer.sync(this.mcpServerTransportProvider); } - @Override - protected McpClient.SyncSpec getMcpClientBuilder() { - return McpClient - .sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT) - .sseEndpoint(CUSTOM_SSE_ENDPOINT) - .build()) - .requestTimeout(Duration.ofHours(10)); - } - @AfterEach public void after() { if (mcpServerTransportProvider != null) { mcpServerTransportProvider.closeGracefully().block(); } - if (tomcat != null) { - try { - tomcat.stop(); - tomcat.destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } + } + + @Override + protected void prepareClients(int port, String mcpEndpoint) { } @Test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java index 6f4ecb5..6111757 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java @@ -16,12 +16,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import java.util.function.Function; -import java.util.function.Function; -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 ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; @@ -54,21 +49,17 @@ import net.javacrumbs.jsonunit.core.Option; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.web.client.RestClient; - import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.APPLICATION_JSON; import static io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport.TEXT_EVENT_STREAM; import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; @@ -89,7 +80,9 @@ class HttpServletStatelessIntegrationTests { private static final int MAX_REQUEST_SIZE = 2048; - private HttpServletStatelessServerTransport mcpStatelessServerTransport; + private static Tomcat tomcat; + + private static HttpServletStatelessServerTransport mcpStatelessServerTransport; private final McpClient.SyncSpec clientBuilder = McpClient .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) @@ -98,11 +91,9 @@ class HttpServletStatelessIntegrationTests { .initializationTimeout(Duration.ofHours(10)) .requestTimeout(Duration.ofHours(10)); - private Tomcat tomcat; - - @BeforeEach - public void before() { - this.mcpStatelessServerTransport = HttpServletStatelessServerTransport.builder() + @BeforeAll + public static void beforeAll() { + mcpStatelessServerTransport = HttpServletStatelessServerTransport.builder() .messageEndpoint(CUSTOM_MESSAGE_ENDPOINT) .maxRequestSize(MAX_REQUEST_SIZE) .build(); @@ -117,8 +108,8 @@ class HttpServletStatelessIntegrationTests { } } - @AfterEach - public void after() { + @AfterAll + public static void afterAll() { if (mcpStatelessServerTransport != null) { mcpStatelessServerTransport.closeGracefully().block(); } @@ -155,7 +146,7 @@ class HttpServletStatelessIntegrationTests { return callResponse; }); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) .build(); @@ -173,22 +164,16 @@ class HttpServletStatelessIntegrationTests { assertThat(response).isNotNull(); assertThat(response).isEqualTo(callResponse); } - finally { - mcpServer.close(); - } } @Test void testInitialize() { - var mcpServer = McpServer.sync(mcpStatelessServerTransport).build(); + McpServer.sync(mcpStatelessServerTransport).build(); try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); } - finally { - mcpServer.close(); - } } // --------------------------------------- @@ -208,7 +193,7 @@ class HttpServletStatelessIntegrationTests { return completionResponse; }; - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpStatelessServerFeatures.SyncPromptSpecification(Prompt.builder("code_review") .title("Code review") @@ -241,9 +226,6 @@ class HttpServletStatelessIntegrationTests { assertThat(completeRequest.get().argument().value()).isEqualTo("py"); assertThat(completeRequest.get().ref().type()).isEqualTo(PromptReference.TYPE); } - finally { - mcpServer.close(); - } } @Test @@ -265,7 +247,7 @@ class HttpServletStatelessIntegrationTests { .of(PromptArgument.builder("topic").title("Topic").description("string").required(false).build())) .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .prompts( new McpStatelessServerFeatures.SyncPromptSpecification(prompt, @@ -291,9 +273,6 @@ class HttpServletStatelessIntegrationTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } - finally { - mcpServer.close(); - } } @Test @@ -313,7 +292,7 @@ class HttpServletStatelessIntegrationTests { .mimeType("text/plain") .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .resourceTemplates( new McpStatelessServerFeatures.SyncResourceTemplateSpecification(template, @@ -339,14 +318,11 @@ class HttpServletStatelessIntegrationTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } - finally { - mcpServer.close(); - } } @Test void testCompletionForNonExistentPromptReturnsInvalidParams() { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -364,14 +340,11 @@ class HttpServletStatelessIntegrationTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(ErrorCodes.INVALID_PARAMS); } - finally { - mcpServer.close(); - } } @Test void testCompletionForNonExistentResourceReturnsResourceNotFound() { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -390,9 +363,6 @@ class HttpServletStatelessIntegrationTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND); } - finally { - mcpServer.close(); - } } // --------------------------------------- @@ -421,7 +391,7 @@ class HttpServletStatelessIntegrationTests { .build(); }); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -459,9 +429,6 @@ class HttpServletStatelessIntegrationTests { .isEqualTo(json(""" {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); } - finally { - mcpServer.close(); - } } @Test @@ -492,7 +459,7 @@ class HttpServletStatelessIntegrationTests { }) .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -517,9 +484,6 @@ class HttpServletStatelessIntegrationTests { {"name":"John","age":30}"""), json(""" {"name":"Peter","age":25}""")); } - finally { - mcpServer.closeGracefully(); - } } @Test @@ -545,7 +509,7 @@ class HttpServletStatelessIntegrationTests { .build()) .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -572,9 +536,6 @@ class HttpServletStatelessIntegrationTests { McpSchema.TextContent.builder("Error calling tool: Simulated in-handler error").build()); assertThat(response.structuredContent()).isNull(); } - finally { - mcpServer.closeGracefully(); - } } @Test @@ -599,7 +560,7 @@ class HttpServletStatelessIntegrationTests { .build(); }); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -621,9 +582,6 @@ class HttpServletStatelessIntegrationTests { String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); assertThat(errorMessage).contains("Validation failed"); } - finally { - mcpServer.close(); - } } @Test @@ -643,7 +601,7 @@ class HttpServletStatelessIntegrationTests { return CallToolResult.builder().addTextContent("Calculation completed").build(); }); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .instructions("bla") @@ -667,9 +625,6 @@ class HttpServletStatelessIntegrationTests { assertThat(errorMessage).isEqualTo( "Response missing structured content which is expected when calling tool with non-empty outputSchema"); } - finally { - mcpServer.close(); - } } @Test @@ -738,9 +693,6 @@ class HttpServletStatelessIntegrationTests { .isEqualTo(json(""" {"count":3,"message":"Dynamic execution"}""")); } - finally { - mcpServer.close(); - } } @Test @@ -787,13 +739,11 @@ class HttpServletStatelessIntegrationTests { assertThat(jsonrpcResponse.error()).isNotNull(); assertThat(jsonrpcResponse.error().code()).isEqualTo(ErrorCodes.INTERNAL_ERROR); assertThat(jsonrpcResponse.error().message()).isEqualTo("testing"); - - mcpServer.close(); } @Test void testMissingHandlerReturnsMethodNotFoundError() { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) .build(); @@ -828,9 +778,6 @@ class HttpServletStatelessIntegrationTests { assertThat(response.get().error().code()).isEqualTo(McpSchema.ErrorCodes.METHOD_NOT_FOUND); assertThat(response.get().error().message()).isEqualTo("Method not found: foo/bar"); } - finally { - mcpServer.closeGracefully(); - } } @Test @@ -841,7 +788,7 @@ class HttpServletStatelessIntegrationTests { handlerLogger.addAppender(logAppender); try { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) .build(); @@ -849,9 +796,6 @@ class HttpServletStatelessIntegrationTests { try (var mcpClient = clientBuilder.build()) { mcpClient.initialize(); // automatically sends notifications/initialized } - finally { - mcpServer.close(); - } } finally { handlerLogger.detachAppender(logAppender); @@ -869,7 +813,7 @@ class HttpServletStatelessIntegrationTests { handlerLogger.addAppender(logAppender); try { - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) .build(); @@ -878,9 +822,7 @@ class HttpServletStatelessIntegrationTests { mcpClient.initialize(); mcpClient.rootsListChangedNotification(); } - finally { - mcpServer.close(); - } + } finally { handlerLogger.detachAppender(logAppender); @@ -913,7 +855,7 @@ class HttpServletStatelessIntegrationTests { .build()) .build(); - var mcpServer = McpServer.sync(mcpStatelessServerTransport) + McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().tools(false).build()) .tools(tool1) .build(); @@ -929,57 +871,48 @@ class HttpServletStatelessIntegrationTests { .isInstanceOf(RuntimeException.class) .hasMessageContaining("413"); } - finally { - mcpServer.closeGracefully(); - } } @Test void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception { - var mcpServer = McpServer.sync(mcpStatelessServerTransport).build(); + McpServer.sync(mcpStatelessServerTransport).build(); + var httpClient = HttpClient.newHttpClient(); - try { - var httpClient = HttpClient.newHttpClient(); + // A publisher with unknown content length forces chunked transfer + // encoding, bypassing the Content-Length header check and exercising the + // body byte count + byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8); + HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() { + @Override + public long contentLength() { + return -1; + } - // A publisher with unknown content length forces chunked transfer - // encoding, bypassing the Content-Length header check and exercising the - // body byte count - byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8); - HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() { - @Override - public long contentLength() { - return -1; - } + @Override + public void subscribe(java.util.concurrent.Flow.Subscriber subscriber) { + subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() { + @Override + public void request(long n) { + subscriber.onNext(ByteBuffer.wrap(oversizedBody)); + subscriber.onComplete(); + } - @Override - public void subscribe(java.util.concurrent.Flow.Subscriber subscriber) { - subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() { - @Override - public void request(long n) { - subscriber.onNext(ByteBuffer.wrap(oversizedBody)); - subscriber.onComplete(); - } + @Override + public void cancel() { + } + }); + } + }; - @Override - public void cancel() { - } - }); - } - }; + var request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM) + .POST(chunkedPublisher) + .build(); - var request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + PORT + CUSTOM_MESSAGE_ENDPOINT)) - .header("Content-Type", "application/json") - .header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM) - .POST(chunkedPublisher) - .build(); - - var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); - assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); - } - finally { - mcpServer.closeGracefully(); - } + var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); } private double evaluateExpression(String expression) { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessSharedIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessSharedIntegrationTests.java new file mode 100644 index 0000000..452eac0 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessSharedIntegrationTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.time.Duration; +import java.util.Map; +import java.util.stream.Stream; + +import io.modelcontextprotocol.AbstractStatelessIntegrationTests; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification; +import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification; +import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport; +import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import jakarta.servlet.http.HttpServletRequest; +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.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.provider.Arguments; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Runs the shared stateless integration suite over the servlet stateless transport. + */ +@Timeout(15) +class HttpServletStatelessSharedIntegrationTests extends AbstractStatelessIntegrationTests { + + private static final int PORT = TomcatTestUtil.findAvailablePort(); + + private static final String MESSAGE_ENDPOINT = "/mcp/message"; + + static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (request) -> McpTransportContext + .create(Map.of("important", "value")); + + // Tomcat is started once for the whole class; each test swaps in its own transport + private static final TomcatTestUtil.DelegatingServlet MCP_SERVLET = new TomcatTestUtil.DelegatingServlet(); + + private static Tomcat tomcat; + + private HttpServletStatelessServerTransport mcpServerTransport; + + static Stream clientsForTesting() { + return Stream.of(Arguments.of("httpclient")); + } + + @BeforeAll + public static void beforeAll() { + tomcat = TomcatTestUtil.createTomcatServer("", PORT, MCP_SERVLET); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + @AfterAll + public static void afterAll() { + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + + @BeforeEach + public void before() { + this.mcpServerTransport = HttpServletStatelessServerTransport.builder() + .contextExtractor(TEST_CONTEXT_EXTRACTOR) + .messageEndpoint(MESSAGE_ENDPOINT) + .build(); + MCP_SERVLET.setDelegate(this.mcpServerTransport); + + prepareClients(PORT, MESSAGE_ENDPOINT); + } + + @Override + protected void prepareClients(int port, String mcpEndpoint) { + this.clientBuilders.put("httpclient", McpClient + .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint(mcpEndpoint).build()) + .initializationTimeout(Duration.ofHours(10)) + .requestTimeout(Duration.ofHours(10))); + } + + @Override + protected StatelessAsyncSpecification prepareAsyncServerBuilder() { + return McpServer.async(this.mcpServerTransport); + } + + @Override + protected StatelessSyncSpecification prepareSyncServerBuilder() { + return McpServer.sync(this.mcpServerTransport); + } + + @AfterEach + public void after() { + if (this.mcpServerTransport != null) { + this.mcpServerTransport.closeGracefully().block(); + } + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java index cfeb16b..c6796ce 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java @@ -1,21 +1,30 @@ /* - * Copyright 2024 - 2024 the original author or authors. + * Copyright 2024 - 2026 the original author or authors. */ package io.modelcontextprotocol.server; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.stream.Stream; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import io.modelcontextprotocol.AbstractMcpClientServerIntegrationTests; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; @@ -24,23 +33,38 @@ import io.modelcontextprotocol.server.McpServer.AsyncSpecification; import io.modelcontextprotocol.server.McpServer.SyncSpecification; import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import io.modelcontextprotocol.spec.HttpHeaders; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.KeepAliveScheduler; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; -import org.awaitility.Awaitility; +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.junit.jupiter.params.provider.Arguments; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import reactor.test.StepVerifier; +import reactor.test.scheduler.VirtualTimeScheduler; +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +/** + * Based on {@link AbstractMcpClientServerIntegrationTests} for basic client <> server + * integration tests. Also contains some tests specific to streamable HTTP, around + * resumability, connection lifecycle and session management. + */ @Timeout(15) class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests { @@ -48,32 +72,79 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg private static final String MESSAGE_ENDPOINT = "/mcp/message"; + // Tomcat is started once for the whole class; each test swaps in its own transport + private static final TomcatTestUtil.DelegatingServlet MCP_SERVLET = new TomcatTestUtil.DelegatingServlet(); + + private static Tomcat tomcat; + private HttpServletStreamableServerTransportProvider mcpServerTransportProvider; - private Tomcat tomcat; + // Keep alive is fast. A ping failure releases the stream, so listening + // steams are released quickly. + private final Duration KEEP_ALIVE_INTERVAL = Duration.ofMillis(150); + + // Sweeping is slower than keep-alive, so that a failed ping doesn't immediately + // result in a session sweep + private final Duration SESSION_SWEEP_INTERVAL = KEEP_ALIVE_INTERVAL.multipliedBy(2); + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @Override + protected void awaitClientStreamEstablished() { + var timeout = Duration.ofSeconds(5); + await().atMost(timeout).untilAsserted(() -> { + assertThat(MCP_SERVLET.isStreamEstablished()) + .withFailMessage("[Failed to observe MCP Client connection within %s]", timeout) + .isTrue(); + }); + } static Stream clientsForTesting() { return Stream.of(Arguments.of("httpclient")); } + @BeforeAll + public static void beforeAll() { + tomcat = TomcatTestUtil.createTomcatServer("", PORT, MCP_SERVLET); + try { + tomcat.start(); + assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED); + } + catch (Exception e) { + throw new RuntimeException("Failed to start Tomcat", e); + } + } + + @AfterAll + public static void afterAll() { + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } + } + @BeforeEach public void before() { // Create and configure the transport provider mcpServerTransportProvider = HttpServletStreamableServerTransportProvider.builder() .contextExtractor(TEST_CONTEXT_EXTRACTOR) .mcpEndpoint(MESSAGE_ENDPOINT) - .keepAliveInterval(Duration.ofSeconds(1)) + .keepAliveInterval(KEEP_ALIVE_INTERVAL) .maxRequestSize(MAX_REQUEST_SIZE) + .sessionSweepInterval(SESSION_SWEEP_INTERVAL) .build(); + MCP_SERVLET.setDelegate(mcpServerTransportProvider); - tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider); - try { - tomcat.start(); - assertThat(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()).requestTimeout(Duration.ofHours(10))); } @Override @@ -86,29 +157,11 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg return McpServer.sync(this.mcpServerTransportProvider); } - @Override - protected McpClient.SyncSpec getMcpClientBuilder() { - return McpClient - .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) - .endpoint(MESSAGE_ENDPOINT) - .build()) - .requestTimeout(Duration.ofHours(10)); - } - @AfterEach public void after() { if (mcpServerTransportProvider != null) { mcpServerTransportProvider.closeGracefully().block(); } - if (tomcat != null) { - try { - tomcat.stop(); - tomcat.destroy(); - } - catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); - } - } } @Test @@ -141,7 +194,7 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg .verifyComplete(); // Wait until we've received the response - Awaitility.await().atMost(Duration.ofSeconds(1)).until(() -> response.get() != null); + await().atMost(Duration.ofSeconds(1)).until(() -> response.get() != null); assertThat(response.get().error().code()).isEqualTo(McpSchema.ErrorCodes.METHOD_NOT_FOUND); assertThat(response.get().error().message()).isEqualTo("Method not found: foo/bar"); @@ -149,7 +202,10 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg finally { mcpServer.close(); } + } + @Override + protected void prepareClients(int port, String mcpEndpoint) { } static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext @@ -157,11 +213,10 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg @Test void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception { - var httpClient = HttpClient.newHttpClient(); // A publisher with unknown content length forces chunked transfer encoding, // bypassing the Content-Length header check and exercising the body byte // count - byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8); + byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(UTF_8); HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() { @Override public long contentLength() { @@ -195,4 +250,400 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); } + @Test + void resumedStreamReceivesServerNotifications() { + prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); + + var sessionId = initializeSession(httpClient); + + // Resume the stream the way a client does once its SSE connection broke. The + // resumed stream must become the session listening stream, otherwise the + // reconnected client never receives anything again. + var stream = openListeningStream(httpClient, sessionId, sessionId + "_0"); + + awaitClientStreamEstablished(); + awaitNotification(stream.events()); + } + + @Test + void replacedListeningStreamIsClosed() { + prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); + + var sessionId = initializeSession(httpClient); + + var firstStream = openListeningStream(httpClient, sessionId, null); + awaitClientStreamEstablished(); + awaitNotification(firstStream.events()); + + // stream keeps receiving pings, so we just ensure we've removed the notification + firstStream.events().clear(); + assertThat(firstStream.events()).noneMatch(line -> line.contains("notifications/resources/list_changed")); + + // Resuming installs a new listening stream. The session can no longer address the + // first one, so it must not be left open. the first stream is only closed when + // the second stream is turned on, so we don't need to wait for a client stream to + // be established + var secondStream = openListeningStream(httpClient, sessionId, sessionId + "_0"); + assertThat(firstStream.streamFuture()).succeedsWithin(Duration.ofSeconds(5)); + mcpServerTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null).block(); + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + assertThat(secondStream.events()).anyMatch(line -> line.contains("notifications/resources/list_changed")); + assertThat(firstStream.events()).noneMatch(line -> line.contains("notifications/resources/list_changed")); + }); + } + + @Test + void keepAliveSkipsSessionsWithoutListeningStream() { + var keepAliveLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(KeepAliveScheduler.class); + ListAppender logAppender = new ListAppender<>(); + logAppender.start(); + keepAliveLogger.addAppender(logAppender); + + var virtualTime = VirtualTimeScheduler.getOrSet(); + try { + withTransportProvider(KEEP_ALIVE_INTERVAL, null); + initializeSession(httpClient); + + // go through multiple keep-alive-rounds + virtualTime.advanceTimeBy(KEEP_ALIVE_INTERVAL.multipliedBy(3)); + + assertThat(logAppender.list).as("Should not display any Keep-Alive warning log") + .noneMatch(event -> event.getLevel() == Level.WARN); + } + finally { + keepAliveLogger.detachAppender(logAppender); + logAppender.stop(); + VirtualTimeScheduler.reset(); + } + + } + + @Test + void keepAlivePingsSessionsWithListeningStream() { + prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); + + var sessionId = initializeSession(httpClient); + var stream = openListeningStream(httpClient, sessionId, null); + awaitClientStreamEstablished(); + + // Sessions with a listening stream are still pinged + await().atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(stream.events()).anyMatch(line -> line.contains("\"method\":\"ping\""))); + } + + @Test + void unansweredKeepAlivePingReleasesTheStreamButKeepsTheSession() { + prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); + + var sessionId = initializeSession(httpClient); + var stream = openListeningStream(httpClient, sessionId, null); + awaitClientStreamEstablished(); + + // Nothing answers the pings the server writes to this stream, which is how a + // connection whose client is gone looks: the writes keep succeeding until the + // peer resets. The server must not hold on to it. + assertThat(stream.streamFuture()).succeedsWithin(Duration.ofSeconds(10)); + assertThat(stream.events()).anyMatch(line -> line.contains("\"method\":\"ping\"")); + + // The session itself survives, so a client can come back to it + assertThat(postNotification(httpClient, sessionId)).isEqualTo(HttpServletResponse.SC_ACCEPTED); + } + + @Test + void sessionEviction() { + var virtualTime = VirtualTimeScheduler.getOrSet(); + try { + withTransportProvider(null, SESSION_SWEEP_INTERVAL); + var sessionId = initializeSession(httpClient); + + // One client request every sweep interval/2, the session remains active + // We do 5 total requests so the total time is > 2 sweep intervals, verifying + // that the sweeper does not reclaim a session + for (int i = 1; i < 6; i++) { + virtualTime.advanceTimeBy(SESSION_SWEEP_INTERVAL.dividedBy(2)); + assertThat(postNotification(httpClient, sessionId)).as("Session should be active for request #%s", i) + .isEqualTo(HttpServletResponse.SC_ACCEPTED); + } + + virtualTime.advanceTimeBy(SESSION_SWEEP_INTERVAL.multipliedBy(2)); + assertThat(postNotification(httpClient, sessionId)).as("Session should be sweeped after SWEEP_INTERVAL * 2") + .isEqualTo(HttpServletResponse.SC_NOT_FOUND); + } + finally { + VirtualTimeScheduler.reset(); + } + } + + @Test + void sessionHoldingAnOpenStreamIsNotEvicted() { + var virtualTime = VirtualTimeScheduler.getOrSet(); + try { + withTransportProvider(null, SESSION_SWEEP_INTERVAL); + var sessionId = initializeSession(httpClient); + openListeningStream(httpClient, sessionId, null); + awaitClientStreamEstablished(); + + virtualTime.advanceTimeBy(SESSION_SWEEP_INTERVAL.multipliedBy(2)); + assertThat(postNotification(httpClient, sessionId)) + .as("Session should not be sweeped when a GET SSE stream is open") + .isEqualTo(HttpServletResponse.SC_ACCEPTED); + } + finally { + VirtualTimeScheduler.reset(); + } + } + + @Test + void sessionEvictionAfterReleasingStream() { + var virtualTime = VirtualTimeScheduler.getOrSet(); + try { + withTransportProvider(null, SESSION_SWEEP_INTERVAL); + var sessionId = initializeSession(httpClient); + var clientStream = openListeningStream(httpClient, sessionId, null); + awaitClientStreamEstablished(); + // "prep" the client so we can close the stream by sending data: the + // subscription + // in the clientStream is only present when the client has received data + awaitNotification(clientStream.events()); + virtualTime.advanceTimeBy(SESSION_SWEEP_INTERVAL.multipliedBy(2)); + assertThat(postNotification(httpClient, sessionId)).as("Session should be active when a stream is open") + .isEqualTo(HttpServletResponse.SC_ACCEPTED); + + // Close the client stream + clientStream.closeStream(); + + await().atMost(Duration.ofSeconds(5)) + .pollDelay(Duration.ZERO) + .pollInterval(Duration.ZERO) + .untilAsserted(() -> { + // send a message so the server realizes the client is gone might take + // a few tries before the internal buffer fills up and the connection + // errors + mcpServerTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null) + .block(); + // eventually, the session should be sweepable + virtualTime.advanceTimeBy(SESSION_SWEEP_INTERVAL.multipliedBy(2)); + assertThat(postNotification(httpClient, sessionId)) + .as("Session should be sweeped after a stream is released") + .isEqualTo(HttpServletResponse.SC_NOT_FOUND); + }); + } + finally { + VirtualTimeScheduler.reset(); + } + } + + @Test + void sessionIsNotEvictedWithoutSweepInterval() { + var virtualTime = VirtualTimeScheduler.getOrSet(); + try { + withTransportProvider(KEEP_ALIVE_INTERVAL, null); + var sessionId = initializeSession(httpClient); + + virtualTime.advanceTimeBy(SESSION_SWEEP_INTERVAL.multipliedBy(10)); + assertThat(postNotification(httpClient, sessionId)) + .as("Session should never be sweeped when there is no sessionSweepInterval") + .isEqualTo(HttpServletResponse.SC_ACCEPTED); + } + finally { + VirtualTimeScheduler.reset(); + } + } + + /** + * A client which aborts a tool call must not leave its session behind. + */ + @Test + void sessionIsEvictedWhenTheClientAbortsAResponseStream() { + var virtualTime = VirtualTimeScheduler.getOrSet(); + try { + withTransportProvider(null, SESSION_SWEEP_INTERVAL); + var toolEmissionInterval = Duration.ofMillis(10); + prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .tools(McpServerFeatures.AsyncToolSpecification.builder() + .tool(McpSchema.Tool.builder("hangs", EMPTY_JSON_SCHEMA).description("never returns").build()) + .callHandler((exchange, request) -> Flux + .interval(Duration.ZERO, toolEmissionInterval, Schedulers.newSingle("test-tool")) + .flatMap(tick -> exchange.loggingNotification(McpSchema.LoggingMessageNotification.builder() + .level(McpSchema.LoggingLevel.INFO) + .data("x".repeat(64 * 1024)) + .build())) + .then(Mono.never())) + .build()) + .build(); + var sessionId = initializeSession(httpClient); + + // The POST opens a response SSE stream, which the session counts as an open + // stream for as long as the call is in flight + var responseStream = postToolCall(httpClient, sessionId, "hangs"); + + await().atMost(Duration.ofSeconds(5)) + .pollDelay(Duration.ZERO) + .pollInterval(Duration.ofMillis(10)) + .untilAsserted(() -> { + // We do this in a loop for the wire-side of the connection - data has + // to be buffered then flushed + + // Advance time so the tool emits some data + virtualTime.advanceTimeBy(toolEmissionInterval); + // Verify the data is received + assertThat(responseStream.events()).anyMatch(line -> line.contains("notifications/message")); + }); + virtualTime.advanceTimeBy(SESSION_SWEEP_INTERVAL.multipliedBy(2)); + assertThat(postNotification(httpClient, sessionId)).as("Session with POST stream is active") + .isEqualTo(HttpServletResponse.SC_ACCEPTED); + + // The client gives up on the call and disconnects + responseStream.closeStream(); + + // Nothing is connected to the session anymore, so the sweeper must reclaim + // it. + // Probing only once: any request would count as activity and reset the clock. + virtualTime.advanceTimeBy(SESSION_SWEEP_INTERVAL.multipliedBy(5)); + + assertThat(postNotification(httpClient, sessionId)).isEqualTo(HttpServletResponse.SC_NOT_FOUND); + } + finally { + VirtualTimeScheduler.reset(); + } + } + + /** + * Calls a tool with a POST request, returning a handle on the SSE response stream it + * opens. + */ + private StreamResponse postToolCall(HttpClient httpClient, String sessionId, String toolName) { + var post = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream, application/json") + .header(HttpHeaders.MCP_SESSION_ID, sessionId) + .POST(HttpRequest.BodyPublishers.ofString("{\"jsonrpc\":\"2.0\",\"id\":\"call-1\"," + + "\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\",\"arguments\":{}}}")) + .build(); + Queue events = new ConcurrentLinkedQueue<>(); + var streamRef = new AtomicReference(); + var clientFuture = httpClient.sendAsync(post, HttpResponse.BodyHandlers.ofInputStream()) + .thenAccept(response -> { + streamRef.set(response.body()); + try (var r = new BufferedReader(new InputStreamReader(response.body(), UTF_8))) { + String l; + while ((l = r.readLine()) != null) { + events.add(l); + } + } + catch (IOException e) { + // "closed" here is our own closeStream(), not a failure + } + }); + return new StreamResponse(clientFuture, events, streamRef); + } + + private int postNotification(HttpClient httpClient, String sessionId) { + var notification = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream, application/json") + .header(HttpHeaders.MCP_SESSION_ID, sessionId) + .POST(HttpRequest.BodyPublishers.ofString("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}")) + .build(); + try { + return httpClient.send(notification, HttpResponse.BodyHandlers.ofString()).statusCode(); + } + catch (IOException | InterruptedException e) { + return -1; + } + } + + private String initializeSession(HttpClient httpClient) { + var initialize = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT)) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream, application/json") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"jsonrpc":"2.0","id":"init","method":"initialize","params":{ + "protocolVersion":"2025-06-18","capabilities":{}, + "clientInfo":{"name":"test-client","version":"1.0.0"}}}""")) + .build(); + + HttpResponse response = null; + try { + response = httpClient.send(initialize, HttpResponse.BodyHandlers.ofString()); + } + catch (IOException | InterruptedException e) { + return null; + } + assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_OK); + return response.headers().firstValue(HttpHeaders.MCP_SESSION_ID).orElse(null); + } + + /** + * Opens an SSE listening stream with a GET request, collecting the received lines. + * @return a handle on the stream, whose future completes once the server closes it + */ + private StreamResponse openListeningStream(HttpClient httpClient, String sessionId, String lastEventId) { + var get = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + PORT + MESSAGE_ENDPOINT)) + .header("Accept", "text/event-stream") + .header(HttpHeaders.MCP_SESSION_ID, sessionId); + if (lastEventId != null) { + get.header(HttpHeaders.LAST_EVENT_ID, lastEventId); + } + Queue events = new ConcurrentLinkedQueue<>(); + var streamRef = new AtomicReference(); + var clientFuture = httpClient.sendAsync(get.GET().build(), HttpResponse.BodyHandlers.ofInputStream()) + .thenAccept(response -> { + streamRef.set(response.body()); + try (var r = new BufferedReader(new InputStreamReader(response.body(), UTF_8))) { + String l; + while ((l = r.readLine()) != null) { + events.add(l); + } + } + catch (IOException e) { + // "closed" here is our own stop(), not a failure + } + }); + return new StreamResponse(clientFuture, events, streamRef); + } + + private void awaitNotification(Queue events) { + mcpServerTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null).block(); + await().atMost(Duration.ofSeconds(5)).pollDelay(Duration.ofMillis(100)).untilAsserted(() -> { + assertThat(events).anyMatch(line -> line.contains("notifications/resources/list_changed")); + }); + } + + record StreamResponse(CompletableFuture streamFuture, Queue events, + AtomicReference streamRef) { + + void closeStream() { + // Close listening stream. We retry a few times in case the stream was not + // established on the first try + await().pollDelay(Duration.ZERO).atMost(Duration.ofSeconds(1)).until(() -> { + var stream = streamRef.get(); + if (stream != null) { + stream.close(); + return true; + } + return false; + + }); + } + } + + private void withTransportProvider(Duration keepAliveInterval, Duration sessionSweepInterval) { + mcpServerTransportProvider.closeGracefully().block(); + mcpServerTransportProvider = HttpServletStreamableServerTransportProvider.builder() + .contextExtractor(TEST_CONTEXT_EXTRACTOR) + .mcpEndpoint(MESSAGE_ENDPOINT) + .keepAliveInterval(keepAliveInterval) + // no sweeping: the session must survive every keep-alive round + .sessionSweepInterval(sessionSweepInterval) + .build(); + MCP_SERVLET.setDelegate(mcpServerTransportProvider); + prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build(); + } + } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java index 482085e..377d56c 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java @@ -13,8 +13,8 @@ import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import io.modelcontextprotocol.client.McpClient; @@ -48,19 +48,20 @@ import static org.assertj.core.api.InstanceOfAssertFactories.type; */ class McpCompletionTests { - private HttpServletSseServerTransportProvider mcpServerTransportProvider; - private static final int PORT = TomcatTestUtil.findAvailablePort(); private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message"; - McpClient.SyncSpec clientBuilder; + private static HttpServletSseServerTransportProvider mcpServerTransportProvider; - private Tomcat tomcat; + private static Tomcat tomcat; - @BeforeEach - public void before() { - // Create and con figure the transport provider + final McpClient.SyncSpec clientBuilder = McpClient + .sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT).build()); + + @BeforeAll + public static void beforeAll() { + // Create and configure the transport provider mcpServerTransportProvider = HttpServletSseServerTransportProvider.builder() .messageEndpoint(CUSTOM_MESSAGE_ENDPOINT) .build(); @@ -73,12 +74,10 @@ class McpCompletionTests { catch (Exception e) { throw new RuntimeException("Failed to start Tomcat", e); } - - this.clientBuilder = McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT).build()); } - @AfterEach - public void after() { + @AfterAll + public static void afterAll() { if (mcpServerTransportProvider != null) { mcpServerTransportProvider.closeGracefully().block(); } @@ -88,7 +87,7 @@ class McpCompletionTests { tomcat.destroy(); } catch (LifecycleException e) { - e.printStackTrace(); + throw new RuntimeException("Failed to stop Tomcat", e); } } } @@ -109,7 +108,7 @@ class McpCompletionTests { .size(123L) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build())) @@ -135,8 +134,6 @@ class McpCompletionTests { assertThat(receivedRequest.get().context().arguments()).containsEntry("previous", "value"); assertThat(result.completion().values()).containsExactly("test-completion"); } - - mcpServer.close(); } @Test @@ -153,7 +150,7 @@ class McpCompletionTests { .arguments(List.of(PromptArgument.builder("arg").description("string").required(false).build())) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, (mcpSyncServerExchange, getPromptRequest) -> null)) @@ -178,8 +175,6 @@ class McpCompletionTests { assertThat(contextWasNull.get()).isTrue(); assertThat(result.completion().values()).containsExactly("no-context-completion"); } - - mcpServer.close(); } @Test @@ -197,7 +192,7 @@ class McpCompletionTests { .arguments(List.of(PromptArgument.builder("topic").description("string").required(false).build())) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts( new McpServerFeatures.SyncPromptSpecification(prompt, @@ -224,8 +219,6 @@ class McpCompletionTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } - - mcpServer.close(); } @Test @@ -243,7 +236,7 @@ class McpCompletionTests { .mimeType("text/plain") .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resourceTemplates( new McpServerFeatures.SyncResourceTemplateSpecification(template, @@ -271,13 +264,11 @@ class McpCompletionTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } - - mcpServer.close(); } @Test void testCompletionForNonExistentPromptReturnsInvalidParams() { - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -297,13 +288,11 @@ class McpCompletionTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(ErrorCodes.INVALID_PARAMS); } - - mcpServer.close(); } @Test void testCompletionForNonExistentResourceReturnsResourceNotFound() { - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -324,8 +313,6 @@ class McpCompletionTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND); } - - mcpServer.close(); } @Test @@ -364,7 +351,7 @@ class McpCompletionTests { .size(456L) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build())) @@ -407,8 +394,6 @@ class McpCompletionTests { CompleteResult tableResult2 = mcpClient.completeCompletion(tableRequest2); assertThat(tableResult2.completion().values()).containsExactly("products", "categories", "inventory"); } - - mcpServer.close(); } @Test @@ -443,7 +428,7 @@ class McpCompletionTests { .size(456L) .build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build())) @@ -477,8 +462,6 @@ class McpCompletionTests { CompleteResult resultWithContext = mcpClient.completeCompletion(requestWithContext); assertThat(resultWithContext.completion().values()).containsExactly("users", "orders", "products"); } - - mcpServer.close(); } @Test @@ -488,7 +471,7 @@ class McpCompletionTests { McpSchema.Prompt prompt = Prompt.builder("test-prompt").description("this is a test prompt").build(); - var mcpServer = McpServer.sync(mcpServerTransportProvider) + McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, (mcpSyncServerExchange, getPromptRequest) -> null)) @@ -510,8 +493,6 @@ class McpCompletionTests { CompleteResult completeResult = mcpClient.completeCompletion(request); assertThat(completeResult.completion().values()).isEmpty(); } - - mcpServer.close(); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpSyncListFilteringIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpSyncListFilteringIntegrationTests.java new file mode 100644 index 0000000..a4cbdee --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpSyncListFilteringIntegrationTests.java @@ -0,0 +1,704 @@ +package io.modelcontextprotocol.server; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +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.transport.HttpServletStatelessServerTransport; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.server.transport.TomcatTestUtil; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.servlet.Servlet; +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.AfterParameterizedClassInvocation; +import org.junit.jupiter.params.BeforeParameterizedClassInvocation; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; + +import 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 static org.junit.jupiter.api.Named.named; + +/** + * Tests for {@link McpSyncListFilter} integration with sync and async servers. Most tests + * are implemented using the sync API, as it falls through to the async implementation. + * Some tests leverage the async API where it has specificities. + *

    + * This is parameterized because the configuration API is duplicated across Server and + * StatelessServer implementations. + * + * @author Daniel Garnier-Moiroux + */ +@ParameterizedClass +@MethodSource("serverTypes") +class McpSyncListFilteringIntegrationTests { + + @Parameter + ServerFactory serverFactory; + + private static Tomcat tomcat; + + private static String baseUrl; + + private McpSyncClient mcpClient; + + private McpSyncHttpClientRequestCustomizer requestCustomizer = (builder, method, endpoint, body, context) -> { + + }; + + @BeforeParameterizedClassInvocation + static void createTransportAndStartTomcat(ServerFactory serverFactory) { + var port = TomcatTestUtil.findAvailablePort(); + baseUrl = "http://localhost:" + port; + startTomcat(serverFactory.transport(), port); + } + + @BeforeEach + void setUp() { + var clientTransport = HttpClientStreamableHttpTransport.builder(baseUrl) + .jsonMapper(McpJsonDefaults.getMapper()) + .httpRequestCustomizer((builder, method, endpoint, body, context) -> requestCustomizer.customize(builder, + method, endpoint, body, context)) + .openConnectionOnStartup(true) + .build(); + + mcpClient = McpClient.sync(clientTransport).initializationTimeout(Duration.ofMillis(500)).build(); + + } + + @AfterEach + void tearDown() { + mcpClient.closeGracefully(); + } + + @AfterParameterizedClassInvocation + static void afterAll() { + stopTomcat(); + } + + @Test + void basicFilter() { + var visible = serverFactory.namedSyncTool("visible-tool"); + var hidden = serverFactory.namedSyncTool("hidden-tool"); + + serverFactory.syncServer() + .tools(List.of(visible.spec(), hidden.spec())) + .addToolFilter((context, tool) -> !tool.name().equals("hidden-tool")) + .build(); + + mcpClient.initialize(); + + assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool()); + } + + @Test + void dynamicFilter() { + var toolSpec = serverFactory.namedSyncTool("tool"); + var listCallCount = new AtomicInteger(); + + serverFactory.syncServer() + .tools(List.of(toolSpec.spec())) + .addToolFilter((context, tool) -> listCallCount.incrementAndGet() < 2) + .build(); + + mcpClient.initialize(); + assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool()); + assertThat(mcpClient.listTools().tools()).isEmpty(); + } + + @Test + void contextBasedFilter() { + var toolSpec = serverFactory.namedSyncTool("toolSpec"); + + serverFactory.syncServer() + .tools(List.of(toolSpec.spec())) + .addToolFilter( + (McpTransportContext context, McpSchema.Tool tool) -> !"true".equals(context.get("x-filter-on"))) + .build(); + + mcpClient.initialize(); + assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool()); + + requestCustomizer = (builder, method, endpoint, body, context) -> { + builder.header("x-filter-on", "true"); + }; + assertThat(mcpClient.listTools().tools()).isEmpty(); + } + + @Test + void hiddenToolIsCallable() { + var hidden = serverFactory.namedSyncTool("hidden-tool"); + + serverFactory.syncServer().tools(List.of(hidden.spec())).addToolFilter((context, tool) -> false).build(); + + mcpClient.initialize(); + assertThat(mcpClient.listTools().tools()).isEmpty(); + var response = mcpClient.callTool(McpSchema.CallToolRequest.builder("hidden-tool").arguments(Map.of()).build()); + assertThat(response.content()).containsExactly(McpSchema.TextContent.builder("called hidden-tool").build()); + } + + @Test + void asyncFilter() { + var visible = serverFactory.namedAsyncTool("visible-tool"); + var hidden = serverFactory.namedAsyncTool("hidden-tool"); + + serverFactory.asyncServer() + .tools(List.of(visible.spec(), hidden.spec())) + .addToolFilter( + (ctx, tool) -> Mono.delay(Duration.ofMillis(10)).thenReturn(tool.name().equals("visible-tool"))) + .build(); + + mcpClient.initialize(); + assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool()); + } + + @Test + void filterErrorFailsTheListingWithAnOpaqueInternalError() { + var toolSpec = serverFactory.namedAsyncTool("tool"); + + serverFactory.asyncServer() + .tools(List.of(toolSpec.spec())) + .addToolFilter((ctx, tool) -> Mono.error(new RuntimeException("private information"))) + .build(); + + mcpClient.initialize(); + + // The listing fails, rather than silently omitting the tool: the client has no + // way to tell a filtered-down list from a partial one. + assertThatThrownBy(mcpClient::listTools).isInstanceOf(McpError.class) + .hasMessage("Internal error") + .extracting(error -> ((McpError) error).getJsonRpcError()) + .satisfies(jsonRpcError -> { + assertThat(jsonRpcError.code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR); + // The filter's own diagnostics MUST NOT reach the client. + assertThat(jsonRpcError.message()).doesNotContain("private information"); + assertThat(jsonRpcError.data()).isNull(); + }); + } + + @Test + void filterMcpErrorIsPassedThroughVerbatim() { + var toolSpec = serverFactory.namedAsyncTool("tool"); + + serverFactory.asyncServer() + .tools(List.of(toolSpec.spec())) + .addToolFilter((ctx, + tool) -> Mono.error(McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Step-up authentication required") + .data(Map.of("scope", "tools:read")) + .build())) + .build(); + + mcpClient.initialize(); + + // An McpError is a deliberate choice by the filter author, so it is not scrubbed. + assertThatThrownBy(mcpClient::listTools).isInstanceOf(McpError.class) + .extracting(error -> ((McpError) error).getJsonRpcError()) + .satisfies(jsonRpcError -> { + assertThat(jsonRpcError.code()).isEqualTo(McpSchema.ErrorCodes.INVALID_REQUEST); + assertThat(jsonRpcError.message()).isEqualTo("Step-up authentication required"); + assertThat(jsonRpcError.data()).isEqualTo(Map.of("scope", "tools:read")); + }); + } + + @Test + void filterEmptyCompletionOmits() { + var visible = serverFactory.namedAsyncTool("visible-tool"); + var hidden = serverFactory.namedAsyncTool("hidden-tool"); + + serverFactory.asyncServer().tools(List.of(visible.spec(), hidden.spec())).addToolFilter((ctx, tool) -> { + if (tool.name().equals("hidden-tool")) { + return Mono.empty(); + } + else { + return Mono.just(true); + } + }).build(); + + mcpClient.initialize(); + assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool()); + } + + @Test + void multipleFilters() { + var visible = serverFactory.namedSyncTool("visible-tool"); + var hidden = serverFactory.namedSyncTool("hidden-tool"); + var otherHidden = serverFactory.namedSyncTool("other-hidden-tool"); + + serverFactory.syncServer() + .tools(List.of(visible.spec(), hidden.spec(), otherHidden.spec())) + .addToolFilter((context, tool) -> true) + .addToolFilter((context, tool) -> !tool.name().equals("hidden-tool")) + .addToolFilter((context, tool) -> !tool.name().equals("other-hidden-tool")) + .build(); + + mcpClient.initialize(); + + assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool()); + } + + @Test + void multipleFiltersShortCircuitOnFirstRejection() { + var visible = serverFactory.namedSyncTool("visible-tool"); + var hidden = serverFactory.namedSyncTool("hidden-tool"); + + serverFactory.syncServer() + .tools(List.of(visible.spec(), hidden.spec())) + .addToolFilter((context, tool) -> "visible-tool".equals(tool.name())) + .addToolFilter((context, tool) -> { + if (tool.name().equals("hidden-tool")) { + throw new RuntimeException("filter error"); + } + else { + return true; + } + }) + .build(); + + mcpClient.initialize(); + + assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool()); + } + + @Test + void toolsFilterConsumerSync() { + var toolSpec = serverFactory.namedSyncTool("tool"); + + serverFactory.syncServer() + .tools(List.of(toolSpec.spec())) + .addToolFilter((context, tool) -> false) + .toolFilters(filters -> { + assertThat(filters).hasSize(1); + filters.clear(); + }) + .build(); + + mcpClient.initialize(); + + assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool()); + } + + @Test + void toolsFilterConsumerAsync() { + var toolSpec = serverFactory.namedAsyncTool("tool"); + + serverFactory.asyncServer() + .tools(List.of(toolSpec.spec())) + .addToolFilter((context, tool) -> Mono.just(false)) + .toolFilters(filters -> { + assertThat(filters).hasSize(1); + filters.clear(); + }) + .build(); + + mcpClient.initialize(); + + assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool()); + } + + // ---------------------------------------------------- + // Test infrastructure + // + // ServerBuilderWrapper wraps stateless and stateful servers + // into a common API that the tests can use, basically + // hiding the builders behind a common type. + // + // The wrapper can produce BOTH sync and async variants + // because the filters are different in every case, one + // returning a boolean and the other a Mono. + // The tests use BOTH apis. + // + // The factory allows you to build a wrapper fluently, + // independent from the underlying type. + // + // ---------------------------------------------------- + + static Stream serverTypes() { + return Stream.of(Arguments.arguments(named("stateful", new StatefulServerFactory())), + Arguments.arguments(named("stateless", new StatelessServerFactory()))); + } + + interface ServerBuilderWrapper { + + ServerBuilderWrapper tools(List tools); + + ServerBuilderWrapper addToolFilter(TOOL_FILTER toolFilter); + + ServerBuilderWrapper toolFilters(Consumer> toolFilterConsumer); + + void build(); + + } + + interface ToolWrapper { + + String name(); + + McpSchema.Tool tool(); + + TOOL_SPEC spec(); + + } + + interface ServerFactory { + + ServerBuilderWrapper> syncServer(); + + ServerBuilderWrapper> asyncServer(); + + ToolWrapper namedSyncTool(String name); + + ToolWrapper namedAsyncTool(String name); + + Servlet transport(); + + } + + static class StatefulServerFactory implements + ServerFactory { + + private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider + .builder() + .contextExtractor(request -> { + var headers = new HashMap(); + var names = request.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + headers.put(name, request.getHeader(name)); + } + return McpTransportContext.create(headers); + }) + .build(); + + @Override + public ServerBuilderWrapper> syncServer() { + return new ServerBuilderWrapper<>() { + private final McpServer.SyncSpecification spec = McpServer + .sync(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()); + + @Override + public ServerBuilderWrapper> tools( + List tools) { + spec.tools(tools); + return this; + } + + @Override + public ServerBuilderWrapper> addToolFilter( + McpSyncListFilter toolFilter) { + spec.addToolFilter(toolFilter); + return this; + } + + @Override + public ServerBuilderWrapper> toolFilters( + Consumer>> toolFilterConsumer) { + spec.toolFilters(toolFilterConsumer); + return this; + } + + @Override + public void build() { + spec.build(); + } + }; + } + + @Override + public ServerBuilderWrapper> asyncServer() { + return new ServerBuilderWrapper<>() { + private final McpServer.StreamableServerAsyncSpecification spec = (McpServer.StreamableServerAsyncSpecification) McpServer + .async(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()); + + @Override + public ServerBuilderWrapper> tools( + List tools) { + spec.tools(tools); + return this; + } + + @Override + public ServerBuilderWrapper> addToolFilter( + McpAsyncListFilter toolFilter) { + spec.addToolFilter(toolFilter); + return this; + } + + @Override + public ServerBuilderWrapper> toolFilters( + Consumer>> toolFilterConsumer) { + spec.toolFilters(toolFilterConsumer); + return this; + } + + @Override + public void build() { + spec.build(); + } + }; + } + + @Override + public ToolWrapper namedSyncTool(String name) { + var tool = McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build()) + .callHandler((exchange, request) -> McpSchema.CallToolResult.builder() + .addContent(McpSchema.TextContent.builder("called " + name).build()) + .build()) + .build(); + return new ToolWrapper<>() { + + @Override + public String name() { + return name; + } + + @Override + public McpSchema.Tool tool() { + return tool.tool(); + } + + @Override + public McpServerFeatures.SyncToolSpecification spec() { + return tool; + } + }; + } + + @Override + public ToolWrapper namedAsyncTool(String name) { + var tool = McpServerFeatures.AsyncToolSpecification.builder() + .tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build()) + .callHandler((exchange, + request) -> Mono.just(McpSchema.CallToolResult.builder() + .addContent(McpSchema.TextContent.builder("called " + name).build()) + .build())) + .build(); + return new ToolWrapper<>() { + + @Override + public String name() { + return name; + } + + @Override + public McpSchema.Tool tool() { + return tool.tool(); + } + + @Override + public McpServerFeatures.AsyncToolSpecification spec() { + return tool; + } + }; + + } + + @Override + public Servlet transport() { + return transport; + } + + } + + static class StatelessServerFactory implements + ServerFactory { + + private final HttpServletStatelessServerTransport transport = HttpServletStatelessServerTransport.builder() + .contextExtractor(request -> { + var headers = new HashMap(); + var names = request.getHeaderNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + headers.put(name, request.getHeader(name)); + } + return McpTransportContext.create(headers); + }) + .build(); + + @Override + public ServerBuilderWrapper> syncServer() { + return new ServerBuilderWrapper<>() { + private final McpServer.StatelessSyncSpecification spec = McpServer.sync(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()); + + @Override + public ServerBuilderWrapper> tools( + List tools) { + spec.tools(tools); + return this; + } + + @Override + public ServerBuilderWrapper> addToolFilter( + McpSyncListFilter toolFilter) { + spec.addToolFilter(toolFilter); + return this; + } + + @Override + public ServerBuilderWrapper> toolFilters( + Consumer>> toolFilterConsumer) { + spec.toolFilters(toolFilterConsumer); + return this; + } + + @Override + public void build() { + spec.build(); + } + }; + } + + @Override + public ServerBuilderWrapper> asyncServer() { + return new ServerBuilderWrapper<>() { + private final McpServer.StatelessAsyncSpecification spec = McpServer.async(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()); + + @Override + public ServerBuilderWrapper> tools( + List tools) { + spec.tools(tools); + return this; + } + + @Override + public ServerBuilderWrapper> addToolFilter( + McpAsyncListFilter toolFilter) { + spec.addToolFilter(toolFilter); + return this; + } + + @Override + public ServerBuilderWrapper> toolFilters( + Consumer>> toolFilterConsumer) { + spec.toolFilters(toolFilterConsumer); + return this; + } + + @Override + public void build() { + spec.build(); + } + }; + } + + @Override + public ToolWrapper namedSyncTool(String name) { + var tool = McpStatelessServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build()) + .callHandler((exchange, request) -> McpSchema.CallToolResult.builder() + .addContent(McpSchema.TextContent.builder("called " + name).build()) + .build()) + .build(); + return new ToolWrapper<>() { + + @Override + public String name() { + return name; + } + + @Override + public McpSchema.Tool tool() { + return tool.tool(); + } + + @Override + public McpStatelessServerFeatures.SyncToolSpecification spec() { + return tool; + } + }; + } + + @Override + public ToolWrapper namedAsyncTool(String name) { + var tool = McpStatelessServerFeatures.AsyncToolSpecification.builder() + .tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build()) + .callHandler((exchange, + request) -> Mono.just(McpSchema.CallToolResult.builder() + .addContent(McpSchema.TextContent.builder("called " + name).build()) + .build())) + .build(); + return new ToolWrapper<>() { + + @Override + public String name() { + return name; + } + + @Override + public McpSchema.Tool tool() { + return tool.tool(); + } + + @Override + public McpStatelessServerFeatures.AsyncToolSpecification spec() { + return tool; + } + }; + + } + + @Override + public Servlet transport() { + return transport; + } + + } + + // ---------------------------------------------------- + // 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); + } + } + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java index b94552d..de410a7 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/McpTestRequestRecordingServletFilter.java @@ -60,6 +60,10 @@ public class McpTestRequestRecordingServletFilter implements Filter { return List.copyOf(calls); } + public void clear() { + calls.clear(); + } + public record Call(String method, Map headers, String body) { } 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 index c1dcc7c..fefce73 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityIntegrationTests.java @@ -209,7 +209,7 @@ class ServerTransportSecurityIntegrationTests { public Sse() { transport = HttpServletSseServerTransportProvider.builder() .messageEndpoint("/mcp/message") - .securityValidator(DefaultServerTransportSecurityValidator.builder() + .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:*") .allowedHost("localhost:*") .build()) @@ -242,7 +242,7 @@ class ServerTransportSecurityIntegrationTests { public StreamableHttp() { transport = HttpServletStreamableServerTransportProvider.builder() - .securityValidator(DefaultServerTransportSecurityValidator.builder() + .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:*") .allowedHost("localhost:*") .build()) @@ -276,7 +276,7 @@ class ServerTransportSecurityIntegrationTests { public Stateless() { transport = HttpServletStatelessServerTransport.builder() - .securityValidator(DefaultServerTransportSecurityValidator.builder() + .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:*") .allowedHost("localhost:*") .build()) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java index ab10689..72d0dfc 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java @@ -7,8 +7,11 @@ package io.modelcontextprotocol.server.transport; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.FilterOutputStream; +import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.OutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.time.Duration; @@ -25,7 +28,9 @@ import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import reactor.test.StepVerifier; import static org.assertj.core.api.Assertions.assertThat; @@ -99,7 +104,7 @@ class StdioServerTransportProviderTests { } @Test - void shouldHandleIncomingMessages() throws Exception { + void shouldHandleIncomingMessages() { String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{},\"id\":1}\n"; InputStream stream = new ByteArrayInputStream(jsonMessage.getBytes(StandardCharsets.UTF_8)); @@ -229,7 +234,7 @@ class StdioServerTransportProviderTests { } @Test - void shouldHandleInvalidJsonMessage() throws Exception { + void shouldHandleInvalidJsonMessage() { // Write an invalid JSON message to the input stream String jsonMessage = "{invalid json}\n"; @@ -248,7 +253,7 @@ class StdioServerTransportProviderTests { } @Test - void shouldRejectInboundMessageExceedingMaxSize() throws Exception { + void shouldRejectInboundMessageExceedingMaxSize() { // A line larger than the configured limit that never terminates with a newline. // BufferedReader#readLine would buffer the whole thing; the bounded reader must // abort instead. @@ -291,7 +296,7 @@ class StdioServerTransportProviderTests { } @Test - void shouldHandleSessionClose() throws Exception { + void shouldHandleSessionClose() { // Set session factory transportProvider.setSessionFactory(sessionFactory); @@ -302,4 +307,69 @@ class StdioServerTransportProviderTests { verify(mockSession).closeGracefully(); } + @Test + void shouldHandleConcurrentSendMessage() throws Exception { + int messageCount = 500; + ByteArrayOutputStream output = new ByteArrayOutputStream(); + CountDownLatch writtenMessages = new CountDownLatch(messageCount); + + // Redirect the transport output to a buffer so we can verify every message lands. + // Writes happen serially on the outbound scheduler, so count the + // newline delimiters as they are written. + OutputStream countingOutput = new FilterOutputStream(output) { + + @Override + public void write(int b) throws IOException { + this.out.write(b); + if (b == '\n') { + writtenMessages.countDown(); + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + this.out.write(b, off, len); + for (int i = off; i < off + len; i++) { + if (b[i] == '\n') { + writtenMessages.countDown(); + } + } + } + }; + transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), System.in, countingOutput); + + // Capture the inner McpServerTransport handed to the session factory + AtomicReference transportRef = new AtomicReference<>(); + McpServerSession.Factory capturingFactory = transport -> { + transportRef.set(transport); + return mockSession; + }; + + transportProvider.setSessionFactory(capturingFactory); + + McpServerTransport transport = transportRef.get(); + assertThat(transport).isNotNull(); + + // Fan sendMessage out across 16 parallel rails to race against the unicast sink + Flux concurrentSends = Flux.range(0, messageCount) + .parallel(16) + .runOn(Schedulers.parallel()) + .flatMap(i -> transport + .sendMessage( + new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, "test/notification", Map.of())) + .thenReturn(i)) + .sequential(); + + // Every send should complete successfully (no FAIL_NON_SERIALIZED errors) + StepVerifier.create(concurrentSends).expectNextCount(messageCount).verifyComplete(); + + // Wait until the outbound scheduler has actually written all of them + assertThat(writtenMessages.await(30, TimeUnit.SECONDS)) + .as("all %d messages written, %d still missing", messageCount, writtenMessages.getCount()) + .isTrue(); + + // Every message was written as its own newline-delimited JSON line + assertThat(output.toString(StandardCharsets.UTF_8).lines().count()).isEqualTo(messageCount); + } + } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java index 490e298..8474701 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/TomcatTestUtil.java @@ -7,10 +7,17 @@ package io.modelcontextprotocol.server.transport; import java.io.IOException; import java.net.InetSocketAddress; import java.net.ServerSocket; +import java.util.concurrent.atomic.AtomicBoolean; import jakarta.servlet.Filter; import jakarta.servlet.Servlet; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; import org.apache.catalina.Context; +import org.apache.catalina.Wrapper; import org.apache.catalina.startup.Tomcat; import org.apache.tomcat.util.descriptor.web.FilterDef; import org.apache.tomcat.util.descriptor.web.FilterMap; @@ -37,7 +44,7 @@ public class TomcatTestUtil { Context context = tomcat.addContext(contextPath, baseDir); // Add transport servlet to Tomcat - org.apache.catalina.Wrapper wrapper = context.createWrapper(); + Wrapper wrapper = context.createWrapper(); wrapper.setName("mcpServlet"); wrapper.setServlet(servlet); wrapper.setLoadOnStartup(1); @@ -63,6 +70,71 @@ public class TomcatTestUtil { return tomcat; } + /** + * A servlet forwarding all requests to a delegate that can be swapped between tests. + * Register one with {@link #createTomcatServer} to start Tomcat once per test class + * while still handing each test a freshly built transport. + */ + public static class DelegatingServlet implements Servlet { + + private volatile ServletConfig servletConfig; + + private volatile Servlet delegate; + + // Crude way of tracking whether a GET SSE stream has been established, to ensure + // a Streamable HTTP MCP Client is connected. + // This is an approximation: it switches to "true" whenever the handler is done + // servicing a GET request - even if the request is rejected or if it's a replay. + private final AtomicBoolean sseStreamEstablished = new AtomicBoolean(false); + + /** + * Sets the servlet handling subsequent requests. The delegate is not + * {@link Servlet#init(ServletConfig) initialized}, since the MCP servlet + * transports do not rely on their {@link ServletConfig}. + */ + public void setDelegate(Servlet delegate) { + this.sseStreamEstablished.set(false); + this.delegate = delegate; + } + + @Override + public void init(ServletConfig config) { + this.servletConfig = config; + } + + @Override + public ServletConfig getServletConfig() { + return this.servletConfig; + } + + @Override + public void service(ServletRequest request, ServletResponse response) throws ServletException, IOException { + var current = this.delegate; + if (current == null) { + throw new IllegalStateException("No delegate servlet has been set"); + } + current.service(request, response); + if (request instanceof HttpServletRequest req && req.getMethod().equals("GET")) { + sseStreamEstablished.set(true); + } + } + + @Override + public String getServletInfo() { + return DelegatingServlet.class.getSimpleName(); + } + + @Override + public void destroy() { + this.delegate = null; + } + + public boolean isStreamEstablished() { + return this.sseStreamEstablished.get(); + } + + } + /** * Finds an available port on the local machine. * @return an available port number diff --git a/mcp/pom.xml b/mcp/pom.xml index 50d95e0..5ae1301 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.1 + 2.1.0-SNAPSHOT mcp jar @@ -20,18 +20,42 @@ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + + io.modelcontextprotocol.sdk.mcp + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + java-compile + prepare-package + + + + + + io.modelcontextprotocol.sdk mcp-json-jackson3 - 2.0.1 + 2.1.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-core - 2.0.1 + 2.1.0-SNAPSHOT diff --git a/mcp/src/main/java/module-info.java b/mcp/src/main/java/module-info.java new file mode 100644 index 0000000..51c6891 --- /dev/null +++ b/mcp/src/main/java/module-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +/** + * Aggregator module for the Java MCP SDK. Carries no code of its own; it exists so that + * consumers can depend on the SDK with a single {@code requires} directive. + */ +module io.modelcontextprotocol.sdk.mcp { + + requires transitive io.modelcontextprotocol.sdk.mcp.core; + + requires transitive io.modelcontextprotocol.sdk.mcp.json.jackson3; + + // mcp-core and mcp-json-jackson3 are automatic modules, so they cannot pull their + // own dependencies into the module graph. Requiring them here spares consumers from + // having to repeat these declarations. Drop this block once the SDK modules carry + // their own module descriptors. + requires org.slf4j; + + requires tools.jackson.databind; + + requires com.networknt.schema; + +} diff --git a/pom.xml b/pom.xml index ca54730..bdf0db8 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.0.1 + 2.1.0-SNAPSHOT pom https://github.com/modelcontextprotocol/java-sdk @@ -59,6 +59,10 @@ 17 + + + 3.27.6 6.0.2 5.20.0 @@ -207,6 +211,7 @@ ${project.artifactId} ${project.version} + ${module.name}