diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f055b61..0c79351 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI on: pull_request: {} - workflow_dispatch: {} jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8153de2..9b72445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,15 +13,9 @@ Versioning follows [Semantic Versioning](https://semver.org/); see | Line | Latest | Spec revision | Status | |--------|--------|---------------|--------| -| 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.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.0 — 2026-06-11 @@ -48,8 +42,6 @@ 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) @@ -62,8 +54,6 @@ 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 a7bf123..1adc091 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,12 +35,6 @@ 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: @@ -69,25 +63,15 @@ 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. 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 +3. Push your changes to your fork. +4. Submit a pull request to the main repository. +5. Follow the pull request template. +6. Wait for review. +7. For any follow-up work, please add new commits instead of force-pushing. This will allow the reviewer to focus on incremental changes instead of having to restart the review process. diff --git a/ROADMAP.md b/ROADMAP.md index b71a1b5..e6eccde 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** (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: +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: - **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 83f15d7..d8efa60 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.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.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 | ## Maven Coordinates diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index 1d111dd..0a76c7a 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.1.0-SNAPSHOT + 2.0.1 client-jdk-http-client jar @@ -21,7 +21,6 @@ - io.modelcontextprotocol.sdk.conformance.client.jdk true @@ -29,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 2.1.0-SNAPSHOT + 2.0.1 diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml index ccc721c..32c81ba 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.1.0-SNAPSHOT + 2.0.1 client-spring-http-client jar @@ -21,7 +21,6 @@ - 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 7e411a2..8856e7d 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.1.0-SNAPSHOT + 2.0.1 conformance-tests pom diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml index ce8f558..df75a81 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.1.0-SNAPSHOT + 2.0.1 server-servlet jar @@ -21,7 +21,6 @@ - io.modelcontextprotocol.sdk.conformance.server.servlet true @@ -29,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 2.1.0-SNAPSHOT + 2.0.1 diff --git a/docs/server.md b/docs/server.md index 5948f4a..8d74359 100644 --- a/docs/server.md +++ b/docs/server.md @@ -164,10 +164,7 @@ Key features: - Efficient bidirectional HTTP communication - Session management for multiple client connections - - 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) + - Configurable keep-alive intervals - Security validation support - Graceful shutdown support @@ -444,102 +441,6 @@ 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 4ee9f55..9f4f9ff 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.1.0-SNAPSHOT + 2.0.1 mcp-bom diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index 79a417b..b63b61b 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.1.0-SNAPSHOT + 2.0.1 mcp-core jar @@ -20,10 +20,6 @@ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git - - io.modelcontextprotocol.sdk.mcp.core - - @@ -41,7 +37,7 @@ Bundle-Name: Bundle ${project.groupId} : ${project.artifactId} Bundle-SymbolicName: ${project.groupId}.${project.artifactId} Bundle-Version: ${project.version} - Automatic-Module-Name: ${module.name} + Automatic-Module-Name: ${project.groupId}.${project.artifactId} 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 328566a..1af4eea 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -494,10 +494,9 @@ public interface McpClient { * calling any client operation. This allows to extract thread-locals and hand * them over to the underlying transport. *

- * 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. + * There is no direct equivalent in {@link AsyncSpec}. To achieve the same result, + * append {@code contextWrite(McpTransportContext.KEY, context)} to any + * {@link McpAsyncClient} call. * @param contextProvider A supplier to create a context * @return This builder for method chaining */ @@ -581,21 +580,6 @@ 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 5517823..07a8f5e 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_2025_03_26, - ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); + 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 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 60a8d7c..73522ae 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,13 +258,16 @@ public class StdioClientTransport implements McpClientTransport { @Override public Mono sendMessage(JSONRPCMessage message) { - try { - // busyLooping retries FAIL_NON_SERIALIZED under concurrent senders - this.outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); + 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. return Mono.empty(); } - catch (Sinks.EmissionException e) { - return Mono.error(new RuntimeException("Failed to enqueue message", e)); + else { + return Mono.error(new RuntimeException("Failed to enqueue message")); } } 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 f2951d8..756b39c 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,26 +7,18 @@ 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 4691c13..46a2ccf 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java @@ -11,28 +11,13 @@ 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. Write the - * context under this key to make it visible to the transport, for example - * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}. + * Key for use in Reactor Context to transport the context to user land. */ 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 6e89ab7..7eed339 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,11 +57,6 @@ 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 deleted file mode 100644 index d6b1858..0000000 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 fc1f8a4..ac78c4f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -118,8 +118,6 @@ public class McpAsyncServer { private final ConcurrentHashMap> resourceSubscriptions = new ConcurrentHashMap<>(); - private final McpAsyncListFilter toolFilter; - private List protocolVersions; private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory(); @@ -148,7 +146,6 @@ 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); @@ -180,7 +177,6 @@ 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); @@ -541,30 +537,10 @@ public class McpAsyncServer { private McpRequestHandler toolsListRequestHandler() { return (exchange, params) -> { - // 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()); - }; - } + List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList(); - /** - * 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()); + return Mono.just(McpSchema.ListToolsResult.builder(tools).build()); + }; } private McpRequestHandler toolsCallRequestHandler() { 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 2113fdb..a2333ae 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -12,7 +12,6 @@ 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; @@ -239,7 +238,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.toolFilters); + this.instructions); var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); @@ -269,7 +268,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.toolFilters); + this.instructions); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); @@ -302,8 +301,6 @@ 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 @@ -443,43 +440,6 @@ 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 @@ -870,7 +830,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.toolFilters); + this.rootsChangeHandlers, this.instructions); McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); @@ -905,7 +865,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.toolFilters); + this.rootsChangeHandlers, this.instructions); McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator @@ -940,8 +900,6 @@ 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 @@ -1085,44 +1043,6 @@ 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 @@ -1522,8 +1442,6 @@ 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 @@ -1664,40 +1582,6 @@ 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 @@ -2024,8 +1908,7 @@ 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.toolFilters); + this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); @@ -2059,8 +1942,6 @@ 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 @@ -2201,42 +2082,6 @@ 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 @@ -2579,8 +2424,7 @@ 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.toolFilters); + this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions); 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 77ecbc8..cfa28e6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java @@ -38,7 +38,6 @@ 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, @@ -46,7 +45,7 @@ public class McpServerFeatures { Map prompts, Map completions, List, Mono>> rootsChangeConsumers, - String instructions, List> toolFilters) { + String instructions) { /** * Create an instance and validate the arguments. @@ -59,7 +58,6 @@ 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, @@ -67,7 +65,7 @@ public class McpServerFeatures { Map prompts, Map completions, List, Mono>> rootsChangeConsumers, - String instructions, List> toolFilters) { + String instructions) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -93,7 +91,6 @@ 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(); } /** @@ -141,11 +138,7 @@ public class McpServerFeatures { } return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates, - prompts, completions, rootChangeConsumers, syncSpec.instructions(), - syncSpec.toolFilters() - .stream() - .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution)) - .toList()); + prompts, completions, rootChangeConsumers, syncSpec.instructions()); } } @@ -168,8 +161,7 @@ public class McpServerFeatures { Map resourceTemplates, Map prompts, Map completions, - List>> rootsChangeConsumers, String instructions, - List> toolFilters) { + List>> rootsChangeConsumers, String instructions) { /** * Create an instance and validate the arguments. @@ -182,7 +174,6 @@ 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, @@ -190,8 +181,8 @@ public class McpServerFeatures { Map resourceTemplates, Map prompts, Map completions, - List>> rootsChangeConsumers, String instructions, - List> toolFilters) { + List>> rootsChangeConsumers, + String instructions) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -217,7 +208,6 @@ 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 95b694e..4211233 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -80,8 +80,6 @@ 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, @@ -99,7 +97,6 @@ public class McpStatelessAsyncServer { this.uriTemplateManagerFactory = uriTemplateManagerFactory; this.jsonSchemaValidator = jsonSchemaValidator; this.validateToolInputs = validateToolInputs; - this.toolFilter = McpAsyncListFilter.and(features.toolFilters()); Map> requestHandlers = new HashMap<>(); @@ -420,32 +417,13 @@ public class McpStatelessAsyncServer { private McpStatelessRequestHandler toolsListRequestHandler() { return (ctx, params) -> { - // 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) + List tools = this.tools.stream() .map(McpStatelessServerFeatures.AsyncToolSpecification::tool) - .filterWhen(tool -> this.toolFilter.isVisible(ctx, tool) - .onErrorResume(error -> opaqueListFilterError(tool, error))) - .collectList() - .map(tools -> McpSchema.ListToolsResult.builder(tools).build()); + .toList(); + return Mono.just(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 8ffc2a6..0c1fbfb 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java @@ -37,7 +37,6 @@ 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, @@ -45,7 +44,7 @@ public class McpStatelessServerFeatures { Map resourceTemplates, Map prompts, Map completions, - String instructions, List> toolFilters) { + String instructions) { /** * Create an instance and validate the arguments. @@ -56,7 +55,6 @@ 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, @@ -64,7 +62,7 @@ public class McpStatelessServerFeatures { Map resourceTemplates, Map prompts, Map completions, - String instructions, List> toolFilters) { + String instructions) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -86,7 +84,6 @@ 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(); } /** @@ -126,11 +123,7 @@ public class McpStatelessServerFeatures { }); return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates, - prompts, completions, syncSpec.instructions(), - syncSpec.toolFilters() - .stream() - .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution)) - .toList()); + prompts, completions, syncSpec.instructions()); } } @@ -144,7 +137,6 @@ 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, @@ -152,7 +144,7 @@ public class McpStatelessServerFeatures { Map resourceTemplates, Map prompts, Map completions, - String instructions, List> toolFilters) { + String instructions) { /** * Create an instance and validate the arguments. @@ -163,7 +155,6 @@ 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, @@ -171,7 +162,7 @@ public class McpStatelessServerFeatures { Map resourceTemplates, Map prompts, Map completions, - String instructions, List> toolFilters) { + String instructions) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -196,7 +187,6 @@ 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 deleted file mode 100644 index 01d4d07..0000000 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 725a67a..e96403e 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,7 +7,6 @@ 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; @@ -23,8 +22,7 @@ import io.modelcontextprotocol.util.Assert; * @see ServerTransportSecurityValidator * @see ServerTransportSecurityException */ -public final class DefaultServerTransportSecurityValidator - implements ServerTransportSecurityValidator, ServerHttpHeaderValidator { +public final class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator { private static final String ORIGIN_HEADER = "Origin"; @@ -49,24 +47,27 @@ public final class DefaultServerTransportSecurityValidator } @Override - @Deprecated public void validateHeaders(Map> headers) throws ServerTransportSecurityException { - 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()) { - List hostValues = headerAccessor.getHeader(HOST_HEADER); - if (hostValues == null || hostValues.isEmpty()) { - throw new ServerTransportSecurityException(421, "Invalid Host header"); + 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)); } - validateHost(hostValues.get(0)); + else if (HOST_HEADER.equalsIgnoreCase(entry.getKey())) { + missingHost = false; + List values = entry.getValue(); + if (values == null || values.isEmpty()) { + throw new ServerTransportSecurityException(421, "Invalid Host header"); + } + validateHost(values.get(0)); + } + } + if (!allowedHosts.isEmpty() && missingHost) { + throw new ServerTransportSecurityException(421, "Invalid Host header"); } } @@ -138,37 +139,6 @@ public final class DefaultServerTransportSecurityValidator 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 deleted file mode 100644 index efed4a7..0000000 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 deleted file mode 100644 index a06261c..0000000 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 5901c38..721bdf1 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,6 +8,11 @@ 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; @@ -21,6 +26,21 @@ 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 a14271a..05dd862 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 ServerHttpHeaderValidator httpHeaderValidator; + private final ServerTransportSecurityValidator securityValidator; /** * 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 httpHeaderValidator The HTTP header validator for validating HTTP requests. + * @param securityValidator The security 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, - ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) { + ServerTransportSecurityValidator securityValidator, 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(httpHeaderValidator, "HTTP header validator must not be null"); + Assert.notNull(securityValidator, "Security 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.httpHeaderValidator = httpHeaderValidator; + this.securityValidator = securityValidator; this.requestMaxSize = requestMaxSize; if (keepAliveInterval != null) { @@ -293,7 +293,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement } try { - this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -370,7 +371,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement } try { - this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -617,7 +619,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement private Duration keepAliveInterval; - private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; @@ -700,25 +702,10 @@ 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.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; + this.securityValidator = securityValidator; return this; } @@ -747,7 +734,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement } return new HttpServletSseServerTransportProvider( jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint, - sseEndpoint, keepAliveInterval, contextExtractor, httpHeaderValidator, requestMaxSize); + sseEndpoint, keepAliveInterval, contextExtractor, securityValidator, 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 9cd0d04..54f0ac0 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,6 +7,7 @@ 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; @@ -67,7 +68,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements /** * Security validator for validating HTTP requests. */ - private final ServerHttpHeaderValidator httpHeaderValidator; + private final ServerTransportSecurityValidator securityValidator; /** * Maximum size, in bytes, of a single request body accepted by this transport. @@ -81,24 +82,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 httpHeaderValidator The HTTP header validator for validating HTTP requests. + * @param securityValidator The security 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, - ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) { + ServerTransportSecurityValidator securityValidator, 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(httpHeaderValidator, "HTTP header validator must not be null"); + Assert.notNull(securityValidator, "Security validator must not be null"); Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive"); this.jsonMapper = jsonMapper; this.mcpEndpoint = mcpEndpoint; this.contextExtractor = contextExtractor; - this.httpHeaderValidator = httpHeaderValidator; + this.securityValidator = securityValidator; this.requestMaxSize = requestMaxSize; } @@ -161,7 +162,8 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements } try { - this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -298,7 +300,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; - private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP; private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE; @@ -352,25 +354,10 @@ 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.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; + this.securityValidator = securityValidator; return this; } @@ -397,7 +384,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, - httpHeaderValidator, requestMaxSize); + securityValidator, 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 5f883cb..324a2ec 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,9 +9,8 @@ import java.io.PrintWriter; import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.Set; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import io.modelcontextprotocol.common.McpTransportContext; @@ -22,17 +21,12 @@ 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; @@ -40,10 +34,8 @@ 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 @@ -60,7 +52,6 @@ import reactor.core.scheduler.Schedulers; * @author Zachary German * @author Christian Tzolov * @author Dariusz Jędrzejczyk - * @author Daniel Garnier-Moiroux * @see McpStreamableServerTransportProvider * @see HttpServlet */ @@ -123,14 +114,6 @@ 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; /** @@ -144,18 +127,10 @@ 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 ServerHttpHeaderValidator httpHeaderValidator; + private final ServerTransportSecurityValidator securityValidator; /** * Constructs a new HttpServletStreamableServerTransportProvider instance. @@ -167,122 +142,38 @@ 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 httpHeaderValidator The HTTP header validator for validating HTTP requests. + * @param securityValidator The security 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, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize, - Duration sessionSweepInterval) { + Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator, int requestMaxSize) { 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(httpHeaderValidator, "HTTP header validator must not be null"); + Assert.notNull(securityValidator, "Security 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.httpHeaderValidator = httpHeaderValidator; + this.securityValidator = securityValidator; this.requestMaxSize = requestMaxSize; if (keepAliveInterval != null) { - this.keepAliveScheduler = KeepAliveScheduler.builder(this::sessionsToPing) + this.keepAliveScheduler = KeepAliveScheduler + .builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(sessions.values())) .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 @@ -357,9 +248,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet if (this.keepAliveScheduler != null) { this.keepAliveScheduler.shutdown(); } - if (this.sessionSweeper != null) { - this.sessionSweeper.dispose(); - } }); } @@ -386,7 +274,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet } try { - this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -419,7 +308,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } - this.markSessionActive(sessionId); logger.debug("Handling GET request for session: {}", sessionId); @@ -437,21 +325,61 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport( sessionId, asyncContext, response.getWriter()); - // 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; + // 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(); + } } + else { + // Establish new listening stream + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session + .listeningStream(sessionTransport); - // 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); + 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(); + } - registerAsyncLifecycle(asyncContext, sessionId, listeningStream::releaseTransport); + @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 + } + }); + } } catch (Exception e) { logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); @@ -486,7 +414,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet } try { - this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -525,7 +454,6 @@ 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 { @@ -577,8 +505,6 @@ 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)) @@ -604,16 +530,8 @@ 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 { - responseStream.handle(jsonrpcRequest) + session.responseStream(jsonrpcRequest, sessionTransport) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); } @@ -675,7 +593,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet } try { - this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request)); + Map> headers = HttpServletRequestUtils.extractHeaders(request); + this.securityValidator.validateHeaders(headers); } catch (ServerTransportSecurityException e) { response.sendError(e.getStatusCode(), e.getMessage()); @@ -767,70 +686,6 @@ 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. @@ -840,6 +695,7 @@ 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; @@ -903,17 +759,9 @@ 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()); - 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); + HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId); + this.asyncContext.complete(); } finally { lock.unlock(); @@ -957,6 +805,8 @@ 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); } @@ -970,12 +820,6 @@ 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(); } @@ -995,14 +839,12 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet private McpTransportContextExtractor contextExtractor = ( serverRequest) -> McpTransportContext.EMPTY; - private Duration keepAliveInterval = Duration.ofMinutes(30); + private Duration keepAliveInterval; - private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP; + private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.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. @@ -1054,7 +896,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. Defaults to 30 minutes. + * keep-alive will be scheduled. * @return this builder instance */ public Builder keepAliveInterval(Duration keepAliveInterval) { @@ -1067,25 +909,10 @@ 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.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; + this.securityValidator = securityValidator; return this; } @@ -1102,22 +929,6 @@ 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. @@ -1128,7 +939,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, httpHeaderValidator, requestMaxSize, sessionSweepInterval); + contextExtractor, keepAliveInterval, securityValidator, requestMaxSize); } } 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 deleted file mode 100644 index 6f2a3dc..0000000 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 023f7f3..ce80593 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,8 +6,6 @@ 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 @@ -15,12 +13,9 @@ import java.util.stream.Collectors; * to the MCP specification. * * @author Daniel Garnier-Moiroux - * @see ServerHttpHeaderValidator + * @see DefaultServerTransportSecurityValidator * @see ServerTransportSecurityException - * @deprecated Use {@link ServerHttpHeaderValidator} instead. This interface will be - * removed in a future major version. */ -@Deprecated @FunctionalInterface public interface ServerTransportSecurityValidator { @@ -30,20 +25,6 @@ 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 8370e41..cc51233 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,12 +175,11 @@ public class StdioServerTransportProvider implements McpServerTransportProvider public Mono sendMessage(McpSchema.JSONRPCMessage message) { return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> { - try { - outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); + if (outboundSink.tryEmitNext(message).isSuccess()) { return Mono.empty(); } - catch (Sinks.EmissionException e) { - return Mono.error(new RuntimeException("Failed to enqueue message", e)); + else { + return Mono.error(new RuntimeException("Failed to enqueue message")); } })); } 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 5ac0863..e7fac7b 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java @@ -1,31 +1,29 @@ /* - * Copyright 2024-2026 the original author or authors. + * Copyright 2024-2025 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 io.modelcontextprotocol.common.McpTransportContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import io.modelcontextprotocol.json.TypeRef; + +import io.modelcontextprotocol.common.McpTransportContext; 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; @@ -38,26 +36,12 @@ 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); - /** - * 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 ConcurrentHashMap requestIdToStream = new ConcurrentHashMap<>(); private final String id; @@ -195,91 +179,65 @@ public class McpStreamableServerSession implements McpLoggableSession { } /** - * 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. + * Create a listening stream (the generic HTTP GET request without Last-Event-ID + * header). * @param transport The dedicated SSE transport stream * @return a stream representation */ public McpStreamableServerSessionStream listeningStream(McpStreamableServerTransport transport) { McpStreamableServerSessionStream listeningStream = new McpStreamableServerSessionStream(transport); - 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(); - } + this.listeningStreamRef.set(listeningStream); 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.defer(() -> this.responseStream(transport).handle(jsonrpcRequest)); + 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()); + }); } /** @@ -313,13 +271,22 @@ public class McpStreamableServerSession implements McpLoggableSession { logger.debug("Received response: {}", response); if (response.id() != null) { - var pendingRequest = this.pendingRequests.remove(response.id()); - if (pendingRequest == null) { + var stream = this.requestIdToStream.get(response.id()); + if (stream == null) { return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR) .message("Unexpected response for unknown id " + response.id()) .build()); } - pendingRequest.sink().success(response); + // 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); + } } else { logger.error("Discarded MCP request response without session id. " @@ -337,49 +304,23 @@ 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(() -> { - 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"))); + McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession); + return listeningStream.closeGracefully(); + // TODO: Also close all the open streams })); } @Override public void close() { this.onClose.get().onErrorComplete().subscribe(); - 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"); + McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession); + if (listeningStream != null) { + listeningStream.close(); + } + // TODO: Also close all open streams } /** @@ -421,23 +362,15 @@ 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 McpStreamableServerTransport connection; + private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>(); + + private final McpStreamableServerTransport transport; private final String transportId; @@ -445,11 +378,10 @@ public class McpStreamableServerSession implements McpLoggableSession { /** * Constructor accepting the dedicated transport representing the SSE stream. - * @param connection request-specific SSE transport stream + * @param transport request-specific SSE transport stream */ - public McpStreamableServerSessionStream(McpStreamableServerTransport connection) { - this.connection = connection; - McpStreamableServerSession.this.openStreams.add(this); + public McpStreamableServerSessionStream(McpStreamableServerTransport transport) { + this.transport = transport; 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 @@ -471,20 +403,19 @@ 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 -> { - // 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)); + this.pendingResponses.put(requestId, sink); McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(method, requestId, requestParams); String messageId = this.uuidGenerator.get(); // TODO: store message in history - this.connection.sendMessage(jsonrpcRequest, messageId).subscribe(v -> { + this.transport.sendMessage(jsonrpcRequest, messageId).subscribe(v -> { }, sink::error); - }).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); + }).timeout(requestTimeout).doOnError(e -> { + this.pendingResponses.remove(requestId); + McpStreamableServerSession.this.requestIdToStream.remove(requestId); }).handle((jsonRpcResponse, sink) -> { if (jsonRpcResponse.error() != null) { sink.error(new McpError(jsonRpcResponse.error())); @@ -494,104 +425,42 @@ public class McpStreamableServerSession implements McpLoggableSession { sink.complete(); } else { - sink.next(this.connection.unmarshalFrom(jsonRpcResponse.result(), typeRef)); + sink.next(this.transport.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.connection.sendMessage(jsonrpcNotification, messageId); + return this.transport.sendMessage(jsonrpcNotification, messageId); } @Override public Mono closeGracefully() { return Mono.defer(() -> { - McpStreamableServerSession.this.openStreams.remove(this); - McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()), - "Stream closed"); + this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed"))); + this.pendingResponses.clear(); // If this was the generic stream, reset it McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this, McpStreamableServerSession.this.missingMcpTransportSession); - return this.connection.closeGracefully(); + McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals); + return this.transport.closeGracefully(); }); } @Override public void close() { - McpStreamableServerSession.this.openStreams.remove(this); - McpStreamableServerSession.this.failPendingRequests(request -> this.equals(request.stream()), - "Stream closed"); + this.pendingResponses.values().forEach(s -> s.error(new RuntimeException("Stream closed"))); + this.pendingResponses.clear(); // If this was the generic stream, reset it McpStreamableServerSession.this.listeningStreamRef.compareAndExchange(this, McpStreamableServerSession.this.missingMcpTransportSession); - 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(); + McpStreamableServerSession.this.requestIdToStream.values().removeIf(this::equals); + this.transport.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 7218be5..1fa6b30 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/Assert.java @@ -31,24 +31,6 @@ 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 0199262..6d53ed5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/KeepAliveScheduler.java @@ -1,12 +1,11 @@ /** - * Copyright 2025 - 2026 the original author or authors. + * Copyright 2025 - 2025 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; @@ -29,7 +28,6 @@ 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 { @@ -59,9 +57,6 @@ 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. @@ -69,15 +64,13 @@ 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, Consumer onPingFailure) { + Supplier> mcpSessions) { this.scheduler = scheduler; this.initialDelay = initialDelay; this.interval = interval; this.mcpSessions = mcpSessions; - this.onPingFailure = onPingFailure; } /** @@ -99,14 +92,8 @@ public class KeepAliveScheduler { .doOnNext(tick -> { this.mcpSessions.get() .flatMap(session -> session.sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF) - // 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); - }) + .doOnError(e -> logger.warn("Failed to send keep-alive ping to session {}: {}", session, + e.getMessage())) .onErrorComplete()) .subscribe(); }) @@ -161,15 +148,12 @@ public class KeepAliveScheduler { private Scheduler scheduler = Schedulers.boundedElastic(); - private Duration initialDelay = Duration.ofSeconds(30); + private Duration initialDelay = Duration.ofSeconds(0); 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 @@ -220,28 +204,12 @@ 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, onPingFailure); + return new KeepAliveScheduler(scheduler, initialDelay, interval, mcpSessions); } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncListFilterTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncListFilterTests.java deleted file mode 100644 index 91458f3..0000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncListFilterTests.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * 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 659dfeb..d4cf858 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.validate(emptyAccessor())).doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); } @Test void originHeaderListEmpty() { - assertThatCode(() -> validator.validate(headerAccessor())).doesNotThrowAnyException(); + assertThatThrownBy(() -> validator.validateHeaders(Map.of("Origin", List.of()))).isEqualTo(INVALID_ORIGIN); } @Test void caseInsensitive() { - var accessor = headerAccessor("Origin", "http://localhost:8080"); + var headers = Map.of("origin", List.of("http://localhost:8080")); - assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void exactMatch() { - var accessor = originAccessor("http://localhost:8080"); + var headers = originHeader("http://localhost:8080"); - assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void differentPort() { - var accessor = originAccessor("http://localhost:3000"); + var headers = originHeader("http://localhost:3000"); - assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); } @Test void differentHost() { - var accessor = originAccessor("http://example.com:8080"); + var headers = originHeader("http://example.com:8080"); - assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); } @Test void differentScheme() { - var accessor = originAccessor("https://localhost:8080"); + var headers = originHeader("https://localhost:8080"); - assertThatThrownBy(() -> validator.validate(accessor)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> validator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); } @Nested @@ -99,37 +99,37 @@ class DefaultServerTransportSecurityValidatorTests { @Test void anyPortWithWildcard() { - var accessor = originAccessor("http://localhost:3000"); + var headers = originHeader("http://localhost:3000"); - assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void noPortWithWildcard() { - var accessor = originAccessor("http://localhost"); + var headers = originHeader("http://localhost"); - assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void differentPortWithWildcard() { - var accessor = originAccessor("http://localhost:8080"); + var headers = originHeader("http://localhost:8080"); - assertThatCode(() -> wildcardValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void differentHostWithWildcard() { - var accessor = originAccessor("http://example.com:3000"); + var headers = originHeader("http://example.com:3000"); - assertThatThrownBy(() -> wildcardValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); } @Test void differentSchemeWithWildcard() { - var accessor = originAccessor("https://localhost:3000"); + var headers = originHeader("https://localhost:3000"); - assertThatThrownBy(() -> wildcardValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> wildcardValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); } } @@ -146,23 +146,23 @@ class DefaultServerTransportSecurityValidatorTests { @Test void matchingOneOfMultiple() { - var accessor = originAccessor("http://example.com:3000"); + var headers = originHeader("http://example.com:3000"); - assertThatCode(() -> multipleOriginsValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void matchingWildcardInMultiple() { - var accessor = originAccessor("http://myapp.example.com:9999"); + var headers = originHeader("http://myapp.example.com:9999"); - assertThatCode(() -> multipleOriginsValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> multipleOriginsValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void notMatchingAny() { - var accessor = originAccessor("http://malicious.example.com:1234"); + var headers = originHeader("http://malicious.example.com:1234"); - assertThatThrownBy(() -> multipleOriginsValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> multipleOriginsValidator.validateHeaders(headers)).isEqualTo(INVALID_ORIGIN); } } @@ -176,9 +176,9 @@ class DefaultServerTransportSecurityValidatorTests { .allowedOrigins(List.of("http://localhost:8080", "http://example.com:*")) .build(); - var accessor = originAccessor("http://example.com:3000"); + var headers = originHeader("http://example.com:3000"); - assertThatCode(() -> validator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test @@ -188,11 +188,11 @@ class DefaultServerTransportSecurityValidatorTests { .allowedOrigins(List.of("http://example.com:*", "http://test.com:3000")) .build(); - assertThatCode(() -> validator.validate(originAccessor("http://localhost:8080"))) + assertThatCode(() -> validator.validateHeaders(originHeader("http://localhost:8080"))) .doesNotThrowAnyException(); - assertThatCode(() -> validator.validate(originAccessor("http://example.com:9999"))) + assertThatCode(() -> validator.validateHeaders(originHeader("http://example.com:9999"))) .doesNotThrowAnyException(); - assertThatCode(() -> validator.validate(originAccessor("http://test.com:3000"))) + assertThatCode(() -> validator.validateHeaders(originHeader("http://test.com:3000"))) .doesNotThrowAnyException(); } @@ -210,45 +210,45 @@ class DefaultServerTransportSecurityValidatorTests { @Test void notConfigured() { - assertThatCode(() -> validator.validate(emptyAccessor())).doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(new HashMap<>())).doesNotThrowAnyException(); } @Test void missing() { - assertThatThrownBy(() -> hostValidator.validate(emptyAccessor())).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> hostValidator.validateHeaders(new HashMap<>())).isEqualTo(INVALID_HOST); } @Test void listEmpty() { - assertThatThrownBy(() -> hostValidator.validate(headerAccessor())).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> hostValidator.validateHeaders(Map.of("Host", List.of()))).isEqualTo(INVALID_HOST); } @Test void caseInsensitive() { - var accessor = headerAccessor("Host", "localhost:8080"); + var headers = Map.of("host", List.of("localhost:8080")); - assertThatCode(() -> hostValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void exactMatch() { - var accessor = hostAccessor("localhost:8080"); + var headers = hostHeader("localhost:8080"); - assertThatCode(() -> hostValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> hostValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void differentPort() { - var accessor = hostAccessor("localhost:3000"); + var headers = hostHeader("localhost:3000"); - assertThatThrownBy(() -> hostValidator.validate(accessor)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); } @Test void differentHost() { - var accessor = hostAccessor("example.com:8080"); + var headers = hostHeader("example.com:8080"); - assertThatThrownBy(() -> hostValidator.validate(accessor)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> hostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); } @Nested @@ -261,23 +261,23 @@ class DefaultServerTransportSecurityValidatorTests { @Test void anyPort() { - var accessor = hostAccessor("localhost:3000"); + var headers = hostHeader("localhost:3000"); - assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void noPort() { - var accessor = hostAccessor("localhost"); + var headers = hostHeader("localhost"); - assertThatCode(() -> wildcardHostValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> wildcardHostValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void differentHost() { - var accessor = hostAccessor("example.com:3000"); + var headers = hostHeader("example.com:3000"); - assertThatThrownBy(() -> wildcardHostValidator.validate(accessor)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> wildcardHostValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); } } @@ -293,30 +293,30 @@ class DefaultServerTransportSecurityValidatorTests { @Test void exactMatch() { - var accessor = hostAccessor("example.com:3000"); + var headers = hostHeader("example.com:3000"); - assertThatCode(() -> multipleHostsValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void wildcard() { - var accessor = hostAccessor("myapp.example.com:9999"); + var headers = hostHeader("myapp.example.com:9999"); - assertThatCode(() -> multipleHostsValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> multipleHostsValidator.validateHeaders(headers)).doesNotThrowAnyException(); } @Test void differentHost() { - var accessor = hostAccessor("malicious.example.com:3000"); + var headers = hostHeader("malicious.example.com:3000"); - assertThatThrownBy(() -> multipleHostsValidator.validate(accessor)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); } @Test void differentPort() { - var accessor = hostAccessor("localhost:8080"); + var headers = hostHeader("localhost:8080"); - assertThatThrownBy(() -> multipleHostsValidator.validate(accessor)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> multipleHostsValidator.validateHeaders(headers)).isEqualTo(INVALID_HOST); } } @@ -330,8 +330,10 @@ class DefaultServerTransportSecurityValidatorTests { .allowedHosts(List.of("localhost:8080", "example.com:*")) .build(); - assertThatCode(() -> validator.validate(hostAccessor("example.com:3000"))).doesNotThrowAnyException(); - assertThatCode(() -> validator.validate(hostAccessor("localhost:8080"))).doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:3000"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080"))) + .doesNotThrowAnyException(); } @Test @@ -341,9 +343,11 @@ class DefaultServerTransportSecurityValidatorTests { .allowedHosts(List.of("example.com:*", "test.com:3000")) .build(); - assertThatCode(() -> validator.validate(hostAccessor("localhost:8080"))).doesNotThrowAnyException(); - assertThatCode(() -> validator.validate(hostAccessor("example.com:9999"))).doesNotThrowAnyException(); - assertThatCode(() -> validator.validate(hostAccessor("test.com:3000"))).doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("localhost:8080"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("example.com:9999"))) + .doesNotThrowAnyException(); + assertThatCode(() -> validator.validateHeaders(hostHeader("test.com:3000"))).doesNotThrowAnyException(); } } @@ -361,195 +365,60 @@ class DefaultServerTransportSecurityValidatorTests { @Test void bothValid() { - var accessor = combinedAccessor("http://localhost:8080", "localhost:8080"); + var header = headers("http://localhost:8080", "localhost:8080"); - assertThatCode(() -> combinedValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); } @Test void originValidHostInvalid() { - var accessor = combinedAccessor("http://localhost:8080", "malicious.example.com:8080"); + var header = headers("http://localhost:8080", "malicious.example.com:8080"); - assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); } @Test void originInvalidHostValid() { - var accessor = combinedAccessor("http://malicious.example.com:8080", "localhost:8080"); + var header = headers("http://malicious.example.com:8080", "localhost:8080"); - assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_ORIGIN); + assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_ORIGIN); } @Test void originMissingHostValid() { // Origin missing is OK (same-origin request) - var accessor = combinedAccessor(null, "localhost:8080"); + var header = headers(null, "localhost:8080"); - assertThatCode(() -> combinedValidator.validate(accessor)).doesNotThrowAnyException(); + assertThatCode(() -> combinedValidator.validateHeaders(header)).doesNotThrowAnyException(); } @Test void originValidHostMissing() { // Host missing is NOT OK when allowedHosts is configured - var accessor = combinedAccessor("http://localhost:8080", null); + var header = headers("http://localhost:8080", null); - assertThatThrownBy(() -> combinedValidator.validate(accessor)).isEqualTo(INVALID_HOST); + assertThatThrownBy(() -> combinedValidator.validateHeaders(header)).isEqualTo(INVALID_HOST); } } - @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> originHeader(String origin) { + return Map.of("Origin", List.of(origin)); } - @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> hostHeader(String host) { + return Map.of("Host", List.of(host)); } - 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<>(); + private static Map> headers(String origin, String host) { + var map = new HashMap>(); if (origin != null) { - headers.put("Origin", List.of(origin)); + map.put("Origin", List.of(origin)); } if (host != null) { - headers.put("Host", List.of(host)); + map.put("Host", List.of(host)); } - return new HeaderAccessor() { - @Override - public List getHeader(String name) { - return headers.getOrDefault(name, List.of()); - } - - @Override - public List getHeaderNames() { - return List.copyOf(headers.keySet()); - } - }; + return map; } } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpStreamableServerSessionTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpStreamableServerSessionTests.java deleted file mode 100644 index f53be41..0000000 --- a/mcp-core/src/test/java/io/modelcontextprotocol/spec/McpStreamableServerSessionTests.java +++ /dev/null @@ -1,256 +0,0 @@ -/* - * 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 baa6a22..0038d4e 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/util/AssertTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/AssertTests.java @@ -6,7 +6,6 @@ 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; @@ -28,17 +27,6 @@ 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 5ccb32e..5a6d0d8 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.1.0-SNAPSHOT + 2.0.1 mcp-json-jackson2 jar @@ -20,10 +20,6 @@ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git - - io.modelcontextprotocol.sdk.mcp.json.jackson2 - - @@ -41,7 +37,7 @@ Bundle-Name: Bundle ${project.groupId} : ${project.artifactId} Bundle-SymbolicName: ${project.groupId}.${project.artifactId} Bundle-Version: ${project.version} - Automatic-Module-Name: ${module.name} + Automatic-Module-Name: ${project.groupId}.${project.artifactId} Bundle-ActivationPolicy: lazy Import-Package: io.modelcontextprotocol.json,io.modelcontextprotocol.json.schema, \ *; @@ -78,7 +74,7 @@ io.modelcontextprotocol.sdk mcp-core - 2.1.0-SNAPSHOT + 2.0.1 com.networknt diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index 98ba82b..5eded9e 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.1.0-SNAPSHOT + 2.0.1 mcp-json-jackson3 jar @@ -20,10 +20,6 @@ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git - - io.modelcontextprotocol.sdk.mcp.json.jackson3 - - @@ -41,7 +37,7 @@ Bundle-Name: Bundle ${project.groupId} : ${project.artifactId} Bundle-SymbolicName: ${project.groupId}.${project.artifactId} Bundle-Version: ${project.version} - Automatic-Module-Name: ${module.name} + Automatic-Module-Name: ${project.groupId}.${project.artifactId} Bundle-ActivationPolicy: lazy Import-Package: io.modelcontextprotocol.json,io.modelcontextprotocol.json.schema, \ *; @@ -72,7 +68,7 @@ io.modelcontextprotocol.sdk mcp-core - 2.1.0-SNAPSHOT + 2.0.1 tools.jackson.core diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index 6002b84..6574e82 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.1.0-SNAPSHOT + 2.0.1 mcp-test jar @@ -20,15 +20,11 @@ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git - - io.modelcontextprotocol.sdk.mcp.test - - io.modelcontextprotocol.sdk mcp-core - 2.1.0-SNAPSHOT + 2.0.1 @@ -163,7 +159,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 2.1.0-SNAPSHOT + 2.0.1 test @@ -174,7 +170,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 2.1.0-SNAPSHOT + 2.0.1 test diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index 4e85f26..f6b547a 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -12,7 +12,6 @@ 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; @@ -50,11 +49,10 @@ 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.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; @@ -74,33 +72,20 @@ 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(); - // 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); + abstract protected McpClient.SyncSpec getMcpClientBuilder(); + @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 = clientBuilder + var client = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .requestTimeout(Duration.ofSeconds(1000)) .build()) { @@ -116,12 +101,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Sampling Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageWithoutSamplingCapabilities(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateMessageWithoutSamplingCapabilities() { McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { @@ -134,7 +115,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { try ( // Create client without sampling capabilities - var client = clientBuilder + var client = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample " + "client", "0.0.0").build()) .build()) { @@ -153,12 +134,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateMessageSuccess() { Function samplingHandler = request -> { assertThat(request.messages()).hasSize(1); assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); @@ -199,7 +176,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = clientBuilder + try (var mcpClient = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) @@ -228,14 +205,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws InterruptedException { - - // Client - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateMessageWithRequestTimeoutSuccess() { Function samplingHandler = request -> { assertThat(request.messages()).hasSize(1); assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); @@ -285,7 +256,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .requestTimeout(Duration.ofSeconds(4)) .tools(tool) .build(); - try (var mcpClient = clientBuilder + try (var mcpClient = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) @@ -314,12 +285,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateMessageWithRequestTimeoutFail(String clientType) throws InterruptedException { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateMessageWithRequestTimeoutFail() { Function samplingHandler = request -> { assertThat(request.messages()).hasSize(1); assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class); @@ -364,7 +331,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = clientBuilder + try (var mcpClient = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().sampling().build()) .sampling(samplingHandler) @@ -385,12 +352,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Elicitation Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithoutElicitationCapabilities(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateElicitationWithoutElicitationCapabilities() { 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)) @@ -400,7 +363,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); // Create client without elicitation capabilities - try (var client = clientBuilder.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) + try (var client = getMcpClientBuilder() + .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .build()) { assertThat(client.initialize()).isNotNull(); @@ -418,12 +382,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateElicitationSuccess() { Function formElicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(request.requestedSchema()).isNotNull(); @@ -456,7 +416,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = clientBuilder + try (var mcpClient = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) .elicitation(formElicitationHandler) @@ -481,12 +441,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithApplyDefaults(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateElicitationWithApplyDefaults() { // Client handler returns empty content — SDK should apply defaults Function elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); @@ -520,7 +476,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .applyElicitationDefaults(true) @@ -546,12 +502,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateElicitationWithApplyDefaultsAndUnmodifiableMap() { // Client handler returns an unmodifiable map (Map.of()) — SDK must copy into a // mutable map before applying defaults. Function elicitationHandler = request -> new McpSchema.ElicitResult( @@ -582,7 +534,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .applyElicitationDefaults(true) @@ -606,12 +558,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateElicitationApplyDefaultsDisabledLeavesContentUntouched() { Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>()); @@ -638,7 +586,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 = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .build()) { @@ -660,12 +608,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationApplyDefaultsSkippedOnDecline(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateElicitationApplyDefaultsSkippedOnDecline() { Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.DECLINE, new HashMap<>()); @@ -691,7 +635,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .applyElicitationDefaults(true) @@ -714,12 +658,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationApplyDefaultsPreservesMeta(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateElicitationApplyDefaultsPreservesMeta() { Map meta = Map.of("trace-id", "abc-123"); Function elicitationHandler = request -> new McpSchema.ElicitResult( McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>(), meta); @@ -746,7 +686,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) + try (var mcpClient = getMcpClientBuilder().clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")) .capabilities(ClientCapabilities.builder().elicitation(true, false).build()) .elicitation(elicitationHandler) .applyElicitationDefaults(true) @@ -770,12 +710,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCreateElicitationWithRequestTimeoutSuccess() { Function elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(((McpSchema.ElicitFormRequest) request).requestedSchema()).isNotNull(); @@ -810,7 +746,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = clientBuilder + try (var mcpClient = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) .elicitation(elicitationHandler) @@ -835,14 +771,10 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateElicitationWithRequestTimeoutFail(String clientType) { - + @Test + void testCreateElicitationWithRequestTimeoutFail() { var latch = new CountDownLatch(1); - var clientBuilder = clientBuilders.get(clientType); - Function elicitationHandler = request -> { assertThat(request.message()).isNotEmpty(); assertThat(request.requestedSchema()).isNotNull(); @@ -886,7 +818,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = clientBuilder + try (var mcpClient = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation().build()) .elicitation(elicitationHandler) @@ -907,15 +839,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testCreateUrlElicitationSuccess(String clientType) { + @Test + void testCreateUrlElicitationSuccess() { 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"); @@ -939,7 +868,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = clientBuilder + try (var mcpClient = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .capabilities(ClientCapabilities.builder().elicitation(false, true).build()) .urlElicitation(urlElicitationHandler) @@ -959,12 +888,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testElicitationCompleteNotification(String clientType) throws InterruptedException { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testElicitationCompleteNotification() throws InterruptedException { CountDownLatch notificationLatch = new CountDownLatch(1); AtomicReference notificationRef = new AtomicReference<>(); AtomicReference sessionId = new AtomicReference<>(); @@ -989,7 +914,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build(); - try (var mcpClient = clientBuilder + try (var mcpClient = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .elicitationCompleteConsumer(elicitationCompleteConsumer) // enable elicitation so that we can register an elicitation complete consumer @@ -1015,12 +940,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testElicitationRequiredError(String clientType) throws InterruptedException { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testElicitationRequiredError() { // 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()) @@ -1036,7 +957,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { Function elicitationHandler = request -> ElicitResult .builder(ElicitResult.Action.ACCEPT) .build(); - try (var mcpClient = clientBuilder + try (var mcpClient = getMcpClientBuilder() .clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build()) .urlElicitation(elicitationHandler) .build()) { @@ -1069,11 +990,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Roots Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsSuccess(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testRootsSuccess() { List roots = List.of(Root.builder("uri1://").name("root1").build(), Root.builder("uri2://").name("root2").build()); @@ -1083,13 +1001,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) .build(); - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + try (var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) .build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); - awaitClientStreamEstablished(); assertThat(rootsRef.get()).isNull(); @@ -1119,12 +1036,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsWithoutCapability(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testRootsWithoutCapability() { McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder() .tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build()) .callHandler((exchange, request) -> { @@ -1141,7 +1054,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { try ( // Create client without roots capability // No roots capability - var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build()) { + var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().build()).build()) { assertThat(mcpClient.initialize()).isNotNull(); @@ -1158,25 +1071,21 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsNotificationWithEmptyRootsList(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testRootsNotificationWithEmptyRootsList() { AtomicReference> rootsRef = new AtomicReference<>(); var mcpServer = prepareSyncServerBuilder() .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) .build(); - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + try (var mcpClient = getMcpClientBuilder().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(() -> { @@ -1188,12 +1097,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsWithMultipleHandlers(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testRootsWithMultipleHandlers() { List roots = List.of(Root.builder("uri1://").name("root1").build()); AtomicReference> rootsRef1 = new AtomicReference<>(); @@ -1204,12 +1109,12 @@ public abstract class AbstractMcpClientServerIntegrationTests { .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate)) .build(); - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + try (var mcpClient = getMcpClientBuilder().capabilities(ClientCapabilities.builder().roots(true).build()) .roots(roots) .build()) { assertThat(mcpClient.initialize()).isNotNull(); - awaitClientStreamEstablished(); + mcpClient.rootsListChangedNotification(); await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { @@ -1222,12 +1127,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRootsServerCloseWithActiveSubscription(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testRootsServerCloseWithActiveSubscription() { List roots = List.of(Root.builder("uri1://").name("root1").build()); AtomicReference> rootsRef = new AtomicReference<>(); @@ -1236,13 +1137,13 @@ public abstract class AbstractMcpClientServerIntegrationTests { .rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate)) .build(); - try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build()) + try (var mcpClient = getMcpClientBuilder().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(() -> { @@ -1257,12 +1158,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Tools Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolCallSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testToolCallSuccess() { var responseBodyIsNullOrBlank = new AtomicBoolean(false); var callResponse = McpSchema.CallToolResult.builder() .addContent(McpSchema.TextContent.builder("CALL RESPONSE; ctx=importantValue").build()) @@ -1293,7 +1190,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool1) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1311,12 +1208,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testThrowingToolCallIsCaughtBeforeTimeout() { McpSyncServer mcpServer = prepareSyncServerBuilder() .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(McpServerFeatures.SyncToolSpecification.builder() @@ -1329,7 +1222,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .build()) .build(); - try (var mcpClient = clientBuilder.requestTimeout(Duration.ofMillis(6666)).build()) { + try (var mcpClient = getMcpClientBuilder().requestTimeout(Duration.ofMillis(6666)).build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1344,12 +1237,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolCallSuccessWithTranportContextExtraction(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testToolCallSuccessWithTranportContextExtraction() { var transportContextIsNull = new AtomicBoolean(false); var transportContextIsEmpty = new AtomicBoolean(false); var responseBodyIsNullOrBlank = new AtomicBoolean(false); @@ -1384,7 +1273,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool1) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1404,11 +1293,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolWithNonAsciiCharacters(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testToolWithNonAsciiCharacters() { String inputSchema = """ { "type": "object", @@ -1433,7 +1319,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(nonAsciiTool) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1456,12 +1342,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testToolListChangeHandlingSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testToolListChangeHandlingSuccess() { var callResponse = McpSchema.CallToolResult.builder() .addContent(McpSchema.TextContent.builder("CALL RESPONSE").build()) .build(); @@ -1493,7 +1375,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool1) .build(); - try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> { + try (var mcpClient = getMcpClientBuilder().toolsChangeConsumer(toolsUpdate -> { // perform a blocking call to a remote service try { HttpResponse response = HttpClient.newHttpClient() @@ -1548,15 +1430,11 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testInitialize(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testInitialize() { var mcpServer = prepareSyncServerBuilder().build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1569,16 +1447,13 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Logging Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testLoggingNotification(String clientType) throws InterruptedException { + @Test + void testLoggingNotification() 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()) @@ -1627,7 +1502,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { try ( // Create client with logging notification handler - var mcpClient = clientBuilder.loggingConsumer(notification -> { + var mcpClient = getMcpClientBuilder().loggingConsumer(notification -> { receivedNotifications.add(notification); latch.countDown(); }).build()) { @@ -1677,17 +1552,14 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Progress Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testProgressNotification(String clientType) throws InterruptedException { + @Test + void testProgressNotification() 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) @@ -1730,7 +1602,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { try ( // Create client with progress notification handler - var mcpClient = clientBuilder.progressConsumer(notification -> { + var mcpClient = getMcpClientBuilder().progressConsumer(notification -> { receivedNotifications.add(notification); latch.countDown(); }).build()) { @@ -1791,11 +1663,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Completion Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : Completion call") - @MethodSource("clientsForTesting") - void testCompletionShouldReturnExpectedSuggestions(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testCompletionShouldReturnExpectedSuggestions() { var expectedValues = List.of("python", "pytorch", "pyside"); var completionResponse = new McpSchema.CompleteResult( new CompleteResult.CompleteCompletion(expectedValues, 10, true)); @@ -1821,7 +1690,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { McpSchema.PromptReference.builder("code_review").title("Code review").build(), completionHandler)) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1847,12 +1716,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Ping Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testPingSuccess(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testPingSuccess() { // Create server with a tool that uses ping functionality AtomicReference executionOrder = new AtomicReference<>(""); @@ -1886,7 +1751,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { // Initialize client InitializeResult initResult = mcpClient.initialize(); @@ -1910,11 +1775,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { // --------------------------------------- // Tool Structured Output Schema Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testStructuredOutputValidationSuccess(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testStructuredOutputValidationSuccess() { // Create a tool with output schema Map outputSchema = Map.of( "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation", @@ -1943,7 +1805,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -1983,11 +1845,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "httpclient" }) - void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testStructuredOutputOfObjectArrayValidationSuccess() { // Create a tool with output schema that returns an array of objects Map outputSchema = Map .of( // @formatter:off @@ -2018,7 +1877,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { assertThat(mcpClient.initialize()).isNotNull(); // Call tool with valid structured output of type array @@ -2042,11 +1901,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "httpclient" }) - void testStructuredOutputWithInHandlerError(String clientType) { - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testStructuredOutputWithInHandlerError() { // Create a tool with output schema Map outputSchema = Map.of( "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation", @@ -2072,7 +1928,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -2098,12 +1954,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = { "httpclient" }) - void testStructuredOutputValidationFailure(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testStructuredOutputValidationFailure() { // 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", @@ -2131,7 +1983,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -2152,12 +2004,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testStructuredOutputMissingStructuredContent(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testStructuredOutputMissingStructuredContent() { // Create a tool with output schema Map outputSchema = Map.of("type", "object", "properties", Map.of("result", Map.of("type", "number")), "required", List.of("result")); @@ -2180,7 +2028,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -2202,18 +2050,14 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testStructuredOutputRuntimeToolAddition(String clientType) { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testStructuredOutputRuntimeToolAddition() { // Start server without tools var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); @@ -2283,12 +2127,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { // Resource Subscription Tests // --------------------------------------- - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testResourceSubscription(String clientType) throws InterruptedException { - - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testResourceSubscription() throws InterruptedException { String resourceUri = "test://subscribable-resource"; var receivedContents = new AtomicReference>(); var latch = new CountDownLatch(1); @@ -2306,7 +2146,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .resources(resourceSpec) .build(); - try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> { + try (var mcpClient = getMcpClientBuilder().resourcesUpdateConsumer(contents -> { receivedContents.set(contents); latch.countDown(); }).build()) { @@ -2327,11 +2167,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } } - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testResourceSubscription_afterUnsubscribe_noNotification(String clientType) throws InterruptedException { - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testResourceSubscription_afterUnsubscribe_noNotification() { String resourceUri = "test://subscribable-resource-unsub"; var notificationCount = new java.util.concurrent.atomic.AtomicInteger(0); @@ -2346,7 +2183,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { .resources(resourceSpec) .build(); - try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> notificationCount.incrementAndGet()) + try (var mcpClient = getMcpClientBuilder() + .resourcesUpdateConsumer(contents -> notificationCount.incrementAndGet()) .build()) { mcpClient.initialize(); @@ -2364,11 +2202,8 @@ public abstract class AbstractMcpClientServerIntegrationTests { } // Bounded read - @ParameterizedTest(name = "{0} : {displayName} ") - @MethodSource("clientsForTesting") - void testRejectsWhenContentLengthHeaderExceedsLimit(String clientType) throws Exception { - var clientBuilder = clientBuilders.get(clientType); - + @Test + void testRejectsWhenContentLengthHeaderExceedsLimit() throws Exception { String inputSchema = """ { "type": "object", @@ -2392,7 +2227,7 @@ public abstract class AbstractMcpClientServerIntegrationTests { .tools(tool1) .build(); - try (var mcpClient = clientBuilder.build()) { + try (var mcpClient = getMcpClientBuilder().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 1f44600..547ccc5 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/ServerParameterUtils.java @@ -1,188 +1,21 @@ -/* - * 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() { - 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); + 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("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 1dc4d99..aa8aaa3 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 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. + * 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. * * @author Christian Tzolov * @author Dariusz Jędrzejczyk */ -@Timeout(25) // Giving extra time beyond the client timeout to account for the one-time - // install of the server package +@Timeout(25) // Giving extra time beyond the client timeout to account for initial server + // download 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 c147056..08e5ea6 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 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. + * 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. * * @author Christian Tzolov * @author Dariusz Jędrzejczyk */ -@Timeout(25) // Giving extra time beyond the client timeout to account for the one-time - // install of the server package +@Timeout(25) // Giving extra time beyond the client timeout to account for initial server + // download 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 2843231..563e520 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/common/HttpClientStreamableHttpVersionNegotiationIntegrationTests.java @@ -17,30 +17,25 @@ 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.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; 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 static Tomcat tomcat; + private Tomcat tomcat; private static final int PORT = TomcatTestUtil.findAvailablePort(); - private static final McpTestRequestRecordingServletFilter requestRecordingFilter = new McpTestRequestRecordingServletFilter(); + private final McpTestRequestRecordingServletFilter requestRecordingFilter = new McpTestRequestRecordingServletFilter(); - private static final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider + private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider .builder() .contextExtractor( req -> McpTransportContext.create(Map.of("protocol-version", req.getHeader("MCP-protocol-version")))) @@ -56,13 +51,78 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests { .isError(false) .build(); - private final McpSyncServer mcpServer = McpServer.sync(transport) + McpSyncServer mcpServer = McpServer.sync(transport) .capabilities(McpSchema.ServerCapabilities.builder().tools(false).build()) .tools(McpServerFeatures.SyncToolSpecification.builder().tool(toolSpec).callHandler(toolHandler).build()) .build(); - @BeforeAll - static void startTomcat() { + @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() { tomcat = TomcatTestUtil.createTomcatServer("", PORT, transport, requestRecordingFilter); try { tomcat.start(); @@ -73,8 +133,7 @@ class HttpClientStreamableHttpVersionNegotiationIntegrationTests { } } - @AfterAll - static void stopTomcat() { + private void stopTomcat() { if (tomcat != null) { try { tomcat.stop(); @@ -86,92 +145,4 @@ 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 c4e334f..c1ebe7b 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletSseIntegrationTests.java @@ -27,9 +27,7 @@ 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; @@ -46,42 +44,14 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/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 HttpServletSseServerTransportProvider mcpServerTransportProvider; + private Tomcat tomcat; + 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 @@ -91,13 +61,15 @@ class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationT .sseEndpoint(CUSTOM_SSE_ENDPOINT) .maxRequestSize(MAX_REQUEST_SIZE) .build(); - MCP_SERVLET.setDelegate(mcpServerTransportProvider); - clientBuilders - .put("httpclient", - McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT) - .sseEndpoint(CUSTOM_SSE_ENDPOINT) - .build()).requestTimeout(Duration.ofHours(10))); + 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); + } } @Override @@ -110,15 +82,29 @@ 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(); } - } - - @Override - protected void prepareClients(int port, String mcpEndpoint) { + if (tomcat != null) { + try { + tomcat.stop(); + tomcat.destroy(); + } + catch (LifecycleException e) { + throw new RuntimeException("Failed to stop Tomcat", e); + } + } } @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 6111757..6f4ecb5 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java @@ -16,7 +16,12 @@ 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; @@ -49,17 +54,21 @@ 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.AfterAll; -import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.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; @@ -80,9 +89,7 @@ class HttpServletStatelessIntegrationTests { private static final int MAX_REQUEST_SIZE = 2048; - private static Tomcat tomcat; - - private static HttpServletStatelessServerTransport mcpStatelessServerTransport; + private HttpServletStatelessServerTransport mcpStatelessServerTransport; private final McpClient.SyncSpec clientBuilder = McpClient .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) @@ -91,9 +98,11 @@ class HttpServletStatelessIntegrationTests { .initializationTimeout(Duration.ofHours(10)) .requestTimeout(Duration.ofHours(10)); - @BeforeAll - public static void beforeAll() { - mcpStatelessServerTransport = HttpServletStatelessServerTransport.builder() + private Tomcat tomcat; + + @BeforeEach + public void before() { + this.mcpStatelessServerTransport = HttpServletStatelessServerTransport.builder() .messageEndpoint(CUSTOM_MESSAGE_ENDPOINT) .maxRequestSize(MAX_REQUEST_SIZE) .build(); @@ -108,8 +117,8 @@ class HttpServletStatelessIntegrationTests { } } - @AfterAll - public static void afterAll() { + @AfterEach + public void after() { if (mcpStatelessServerTransport != null) { mcpStatelessServerTransport.closeGracefully().block(); } @@ -146,7 +155,7 @@ class HttpServletStatelessIntegrationTests { return callResponse; }); - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool1) .build(); @@ -164,16 +173,22 @@ class HttpServletStatelessIntegrationTests { assertThat(response).isNotNull(); assertThat(response).isEqualTo(callResponse); } + finally { + mcpServer.close(); + } } @Test void testInitialize() { - McpServer.sync(mcpStatelessServerTransport).build(); + var mcpServer = McpServer.sync(mcpStatelessServerTransport).build(); try (var mcpClient = clientBuilder.build()) { InitializeResult initResult = mcpClient.initialize(); assertThat(initResult).isNotNull(); } + finally { + mcpServer.close(); + } } // --------------------------------------- @@ -193,7 +208,7 @@ class HttpServletStatelessIntegrationTests { return completionResponse; }; - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpStatelessServerFeatures.SyncPromptSpecification(Prompt.builder("code_review") .title("Code review") @@ -226,6 +241,9 @@ class HttpServletStatelessIntegrationTests { assertThat(completeRequest.get().argument().value()).isEqualTo("py"); assertThat(completeRequest.get().ref().type()).isEqualTo(PromptReference.TYPE); } + finally { + mcpServer.close(); + } } @Test @@ -247,7 +265,7 @@ class HttpServletStatelessIntegrationTests { .of(PromptArgument.builder("topic").title("Topic").description("string").required(false).build())) .build(); - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .prompts( new McpStatelessServerFeatures.SyncPromptSpecification(prompt, @@ -273,6 +291,9 @@ class HttpServletStatelessIntegrationTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } + finally { + mcpServer.close(); + } } @Test @@ -292,7 +313,7 @@ class HttpServletStatelessIntegrationTests { .mimeType("text/plain") .build(); - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .resourceTemplates( new McpStatelessServerFeatures.SyncResourceTemplateSpecification(template, @@ -318,11 +339,14 @@ class HttpServletStatelessIntegrationTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } + finally { + mcpServer.close(); + } } @Test void testCompletionForNonExistentPromptReturnsInvalidParams() { - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -340,11 +364,14 @@ class HttpServletStatelessIntegrationTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(ErrorCodes.INVALID_PARAMS); } + finally { + mcpServer.close(); + } } @Test void testCompletionForNonExistentResourceReturnsResourceNotFound() { - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -363,6 +390,9 @@ class HttpServletStatelessIntegrationTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND); } + finally { + mcpServer.close(); + } } // --------------------------------------- @@ -391,7 +421,7 @@ class HttpServletStatelessIntegrationTests { .build(); }); - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -429,6 +459,9 @@ class HttpServletStatelessIntegrationTests { .isEqualTo(json(""" {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); } + finally { + mcpServer.close(); + } } @Test @@ -459,7 +492,7 @@ class HttpServletStatelessIntegrationTests { }) .build(); - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -484,6 +517,9 @@ class HttpServletStatelessIntegrationTests { {"name":"John","age":30}"""), json(""" {"name":"Peter","age":25}""")); } + finally { + mcpServer.closeGracefully(); + } } @Test @@ -509,7 +545,7 @@ class HttpServletStatelessIntegrationTests { .build()) .build(); - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -536,6 +572,9 @@ class HttpServletStatelessIntegrationTests { McpSchema.TextContent.builder("Error calling tool: Simulated in-handler error").build()); assertThat(response.structuredContent()).isNull(); } + finally { + mcpServer.closeGracefully(); + } } @Test @@ -560,7 +599,7 @@ class HttpServletStatelessIntegrationTests { .build(); }); - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .tools(tool) @@ -582,6 +621,9 @@ class HttpServletStatelessIntegrationTests { String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text(); assertThat(errorMessage).contains("Validation failed"); } + finally { + mcpServer.close(); + } } @Test @@ -601,7 +643,7 @@ class HttpServletStatelessIntegrationTests { return CallToolResult.builder().addTextContent("Calculation completed").build(); }); - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().tools(true).build()) .instructions("bla") @@ -625,6 +667,9 @@ class HttpServletStatelessIntegrationTests { assertThat(errorMessage).isEqualTo( "Response missing structured content which is expected when calling tool with non-empty outputSchema"); } + finally { + mcpServer.close(); + } } @Test @@ -693,6 +738,9 @@ class HttpServletStatelessIntegrationTests { .isEqualTo(json(""" {"count":3,"message":"Dynamic execution"}""")); } + finally { + mcpServer.close(); + } } @Test @@ -739,11 +787,13 @@ 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() { - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) .build(); @@ -778,6 +828,9 @@ 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 @@ -788,7 +841,7 @@ class HttpServletStatelessIntegrationTests { handlerLogger.addAppender(logAppender); try { - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) .build(); @@ -796,6 +849,9 @@ class HttpServletStatelessIntegrationTests { try (var mcpClient = clientBuilder.build()) { mcpClient.initialize(); // automatically sends notifications/initialized } + finally { + mcpServer.close(); + } } finally { handlerLogger.detachAppender(logAppender); @@ -813,7 +869,7 @@ class HttpServletStatelessIntegrationTests { handlerLogger.addAppender(logAppender); try { - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .serverInfo("test-server", "1.0.0") .capabilities(ServerCapabilities.builder().build()) .build(); @@ -822,7 +878,9 @@ class HttpServletStatelessIntegrationTests { mcpClient.initialize(); mcpClient.rootsListChangedNotification(); } - + finally { + mcpServer.close(); + } } finally { handlerLogger.detachAppender(logAppender); @@ -855,7 +913,7 @@ class HttpServletStatelessIntegrationTests { .build()) .build(); - McpServer.sync(mcpStatelessServerTransport) + var mcpServer = McpServer.sync(mcpStatelessServerTransport) .capabilities(ServerCapabilities.builder().tools(false).build()) .tools(tool1) .build(); @@ -871,48 +929,57 @@ class HttpServletStatelessIntegrationTests { .isInstanceOf(RuntimeException.class) .hasMessageContaining("413"); } + finally { + mcpServer.closeGracefully(); + } } @Test void rejectsWhenBodyBytesExceedLimitWithoutContentLengthHeader() throws Exception { - McpServer.sync(mcpStatelessServerTransport).build(); - var httpClient = HttpClient.newHttpClient(); + var mcpServer = McpServer.sync(mcpStatelessServerTransport).build(); - // 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; - } + try { + var httpClient = HttpClient.newHttpClient(); - @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(); - } + // 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 cancel() { - } - }); - } - }; + @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(); + } - 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(); + @Override + public void cancel() { + } + }); + } + }; - var response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); - assertThat(response.statusCode()).isEqualTo(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + 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(); + } } 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 deleted file mode 100644 index 452eac0..0000000 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessSharedIntegrationTests.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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 c6796ce..cfeb16b 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStreamableIntegrationTests.java @@ -1,30 +1,21 @@ /* - * Copyright 2024 - 2026 the original author or authors. + * Copyright 2024 - 2024 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; @@ -33,38 +24,23 @@ 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.junit.jupiter.api.AfterAll; +import org.awaitility.Awaitility; 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 { @@ -72,79 +48,32 @@ 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; - // 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(); - }); - } + private Tomcat tomcat; 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(KEEP_ALIVE_INTERVAL) + .keepAliveInterval(Duration.ofSeconds(1)) .maxRequestSize(MAX_REQUEST_SIZE) - .sessionSweepInterval(SESSION_SWEEP_INTERVAL) .build(); - MCP_SERVLET.setDelegate(mcpServerTransportProvider); - clientBuilders - .put("httpclient", - McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT) - .endpoint(MESSAGE_ENDPOINT) - .build()).requestTimeout(Duration.ofHours(10))); + 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); + } } @Override @@ -157,11 +86,29 @@ 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 @@ -194,7 +141,7 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg .verifyComplete(); // Wait until we've received the response - await().atMost(Duration.ofSeconds(1)).until(() -> response.get() != null); + Awaitility.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"); @@ -202,10 +149,7 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg finally { mcpServer.close(); } - } - @Override - protected void prepareClients(int port, String mcpEndpoint) { } static McpTransportContextExtractor TEST_CONTEXT_EXTRACTOR = (r) -> McpTransportContext @@ -213,10 +157,11 @@ 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(UTF_8); + byte[] oversizedBody = "a".repeat(MAX_REQUEST_SIZE + 1).getBytes(StandardCharsets.UTF_8); HttpRequest.BodyPublisher chunkedPublisher = new HttpRequest.BodyPublisher() { @Override public long contentLength() { @@ -250,400 +195,4 @@ 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 377d56c..482085e 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.AfterAll; -import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import io.modelcontextprotocol.client.McpClient; @@ -48,20 +48,19 @@ 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"; - private static HttpServletSseServerTransportProvider mcpServerTransportProvider; + McpClient.SyncSpec clientBuilder; - private static Tomcat tomcat; + private Tomcat tomcat; - final McpClient.SyncSpec clientBuilder = McpClient - .sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT).build()); - - @BeforeAll - public static void beforeAll() { - // Create and configure the transport provider + @BeforeEach + public void before() { + // Create and con figure the transport provider mcpServerTransportProvider = HttpServletSseServerTransportProvider.builder() .messageEndpoint(CUSTOM_MESSAGE_ENDPOINT) .build(); @@ -74,10 +73,12 @@ class McpCompletionTests { catch (Exception e) { throw new RuntimeException("Failed to start Tomcat", e); } + + this.clientBuilder = McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT).build()); } - @AfterAll - public static void afterAll() { + @AfterEach + public void after() { if (mcpServerTransportProvider != null) { mcpServerTransportProvider.closeGracefully().block(); } @@ -87,7 +88,7 @@ class McpCompletionTests { tomcat.destroy(); } catch (LifecycleException e) { - throw new RuntimeException("Failed to stop Tomcat", e); + e.printStackTrace(); } } } @@ -108,7 +109,7 @@ class McpCompletionTests { .size(123L) .build(); - McpServer.sync(mcpServerTransportProvider) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build())) @@ -134,6 +135,8 @@ class McpCompletionTests { assertThat(receivedRequest.get().context().arguments()).containsEntry("previous", "value"); assertThat(result.completion().values()).containsExactly("test-completion"); } + + mcpServer.close(); } @Test @@ -150,7 +153,7 @@ class McpCompletionTests { .arguments(List.of(PromptArgument.builder("arg").description("string").required(false).build())) .build(); - McpServer.sync(mcpServerTransportProvider) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, (mcpSyncServerExchange, getPromptRequest) -> null)) @@ -175,6 +178,8 @@ class McpCompletionTests { assertThat(contextWasNull.get()).isTrue(); assertThat(result.completion().values()).containsExactly("no-context-completion"); } + + mcpServer.close(); } @Test @@ -192,7 +197,7 @@ class McpCompletionTests { .arguments(List.of(PromptArgument.builder("topic").description("string").required(false).build())) .build(); - McpServer.sync(mcpServerTransportProvider) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts( new McpServerFeatures.SyncPromptSpecification(prompt, @@ -219,6 +224,8 @@ class McpCompletionTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } + + mcpServer.close(); } @Test @@ -236,7 +243,7 @@ class McpCompletionTests { .mimeType("text/plain") .build(); - McpServer.sync(mcpServerTransportProvider) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resourceTemplates( new McpServerFeatures.SyncResourceTemplateSpecification(template, @@ -264,11 +271,13 @@ class McpCompletionTests { assertThat(result.completion().total()).isZero(); assertThat(result.completion().hasMore()).isFalse(); } + + mcpServer.close(); } @Test void testCompletionForNonExistentPromptReturnsInvalidParams() { - McpServer.sync(mcpServerTransportProvider) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -288,11 +297,13 @@ class McpCompletionTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(ErrorCodes.INVALID_PARAMS); } + + mcpServer.close(); } @Test void testCompletionForNonExistentResourceReturnsResourceNotFound() { - McpServer.sync(mcpServerTransportProvider) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .build(); @@ -313,6 +324,8 @@ class McpCompletionTests { .extracting(McpSchema.JSONRPCResponse.JSONRPCError::code) .isEqualTo(McpSchema.ErrorCodes.RESOURCE_NOT_FOUND); } + + mcpServer.close(); } @Test @@ -351,7 +364,7 @@ class McpCompletionTests { .size(456L) .build(); - McpServer.sync(mcpServerTransportProvider) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build())) @@ -394,6 +407,8 @@ class McpCompletionTests { CompleteResult tableResult2 = mcpClient.completeCompletion(tableRequest2); assertThat(tableResult2.completion().values()).containsExactly("products", "categories", "inventory"); } + + mcpServer.close(); } @Test @@ -428,7 +443,7 @@ class McpCompletionTests { .size(456L) .build(); - McpServer.sync(mcpServerTransportProvider) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .resources(new McpServerFeatures.SyncResourceSpecification(resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build())) @@ -462,6 +477,8 @@ class McpCompletionTests { CompleteResult resultWithContext = mcpClient.completeCompletion(requestWithContext); assertThat(resultWithContext.completion().values()).containsExactly("users", "orders", "products"); } + + mcpServer.close(); } @Test @@ -471,7 +488,7 @@ class McpCompletionTests { McpSchema.Prompt prompt = Prompt.builder("test-prompt").description("this is a test prompt").build(); - McpServer.sync(mcpServerTransportProvider) + var mcpServer = McpServer.sync(mcpServerTransportProvider) .capabilities(ServerCapabilities.builder().completions().build()) .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, (mcpSyncServerExchange, getPromptRequest) -> null)) @@ -493,6 +510,8 @@ 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 deleted file mode 100644 index a4cbdee..0000000 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpSyncListFilteringIntegrationTests.java +++ /dev/null @@ -1,704 +0,0 @@ -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 de410a7..b94552d 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,10 +60,6 @@ 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 fefce73..c1dcc7c 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") - .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() + .securityValidator(DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:*") .allowedHost("localhost:*") .build()) @@ -242,7 +242,7 @@ class ServerTransportSecurityIntegrationTests { public StreamableHttp() { transport = HttpServletStreamableServerTransportProvider.builder() - .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() + .securityValidator(DefaultServerTransportSecurityValidator.builder() .allowedOrigin("http://localhost:*") .allowedHost("localhost:*") .build()) @@ -276,7 +276,7 @@ class ServerTransportSecurityIntegrationTests { public Stateless() { transport = HttpServletStatelessServerTransport.builder() - .httpHeaderValidator(DefaultServerTransportSecurityValidator.builder() + .securityValidator(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 72d0dfc..ab10689 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,11 +7,8 @@ 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; @@ -28,9 +25,7 @@ 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; @@ -104,7 +99,7 @@ class StdioServerTransportProviderTests { } @Test - void shouldHandleIncomingMessages() { + void shouldHandleIncomingMessages() throws Exception { String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{},\"id\":1}\n"; InputStream stream = new ByteArrayInputStream(jsonMessage.getBytes(StandardCharsets.UTF_8)); @@ -234,7 +229,7 @@ class StdioServerTransportProviderTests { } @Test - void shouldHandleInvalidJsonMessage() { + void shouldHandleInvalidJsonMessage() throws Exception { // Write an invalid JSON message to the input stream String jsonMessage = "{invalid json}\n"; @@ -253,7 +248,7 @@ class StdioServerTransportProviderTests { } @Test - void shouldRejectInboundMessageExceedingMaxSize() { + void shouldRejectInboundMessageExceedingMaxSize() throws Exception { // 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. @@ -296,7 +291,7 @@ class StdioServerTransportProviderTests { } @Test - void shouldHandleSessionClose() { + void shouldHandleSessionClose() throws Exception { // Set session factory transportProvider.setSessionFactory(sessionFactory); @@ -307,69 +302,4 @@ 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 8474701..490e298 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,17 +7,10 @@ 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; @@ -44,7 +37,7 @@ public class TomcatTestUtil { Context context = tomcat.addContext(contextPath, baseDir); // Add transport servlet to Tomcat - Wrapper wrapper = context.createWrapper(); + org.apache.catalina.Wrapper wrapper = context.createWrapper(); wrapper.setName("mcpServlet"); wrapper.setServlet(servlet); wrapper.setLoadOnStartup(1); @@ -70,71 +63,6 @@ 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 5ae1301..50d95e0 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.1.0-SNAPSHOT + 2.0.1 mcp jar @@ -20,42 +20,18 @@ 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.1.0-SNAPSHOT + 2.0.1 io.modelcontextprotocol.sdk mcp-core - 2.1.0-SNAPSHOT + 2.0.1 diff --git a/mcp/src/main/java/module-info.java b/mcp/src/main/java/module-info.java deleted file mode 100644 index 51c6891..0000000 --- a/mcp/src/main/java/module-info.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 bdf0db8..ca54730 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 2.1.0-SNAPSHOT + 2.0.1 pom https://github.com/modelcontextprotocol/java-sdk @@ -59,10 +59,6 @@ 17 - - - 3.27.6 6.0.2 5.20.0 @@ -211,7 +207,6 @@ ${project.artifactId} ${project.version} - ${module.name}