diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0c79351..f055b61 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,6 +2,7 @@ name: CI
on:
pull_request: {}
+ workflow_dispatch: {}
jobs:
build:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b72445..8153de2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,9 +13,15 @@ Versioning follows [Semantic Versioning](https://semver.org/); see
| Line | Latest | Spec revision | Status |
|--------|--------|---------------|--------|
-| 2.0.x | [2.0.0](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.0) (2026-06-11) | 2025-11-25 | Active development |
-| 1.1.x | [1.1.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.3) (2026-05-21) | 2025-06-18 | Security patches only |
-| 0.18.x | [0.18.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.3) (2026-06-09) | 2025-06-18 | Security patches only |
+| 2.0.x | [2.0.1](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.1) (2026-08-19) | 2025-11-25 | Active development |
+| 1.1.x | [1.1.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.4) (2026-08-19) | 2025-06-18 | Security patches only |
+| 0.18.x | [0.18.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.4) (2026-08-19) | 2025-06-18 | Security patches only |
+
+## 2.0.1 — 2026-08-19
+
+- Bound STDIO and HTTP client/server reads to a configurable maximum size
+
+Full notes: [v2.0.1 release](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.1)
## 2.0.0 — 2026-06-11
@@ -42,6 +48,8 @@ Full notes: [v2.0.0 release](https://github.com/modelcontextprotocol/java-sdk/re
## 1.x
+- [1.1.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.4) — 2026-08-19:
+ bound HTTP client/server reads (backport)
- [1.1.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.3) /
[1.0.2](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.0.2) — 2026-05-21:
SSE client transport message-endpoint validation (backports)
@@ -54,6 +62,8 @@ Full notes: [v2.0.0 release](https://github.com/modelcontextprotocol/java-sdk/re
## 0.x
+- [0.18.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.4) — 2026-08-19:
+ bound HTTP client/server reads (backport)
- [0.18.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.3) — 2026-06-09:
security fix for GHSA-hv2w-8mjj-jw22
- [0.18.0](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.0) –
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1adc091..a7bf123 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -35,6 +35,12 @@ propose an enhancement. Bug reports should have a reproducer in the form of a co
sample or a repository attached that the maintainers or contributors can work with to
address the problem.
+## AI agents
+
+**We accept contributions created with the help of AI coding agents, but they must be carefully reviewed by a human who remains accountable for the quality of the contribution.**
+These can be issues or pull requests. For issues, please ensure you describe your particular use-case, and not general considerations found by an AI agent.
+Contributions submitted by GitHub accounts controlled by autonomous AI bots are forbidden.
+
## Making Changes
1. Create a new branch:
@@ -63,15 +69,25 @@ git checkout -b feature/your-feature-name
## Submitting Changes
+Please don't submit pull requests:
+
+- With GitHub accounts managed by autonomous AI bots
+- For already assigned issues (as the assignee is or plans to work on it)
+
+When submitting changes:
+
1. For non-trivial changes, please clarify with the maintainers in an issue whether
you can contribute the change and the desired scope of the change.
2. For trivial changes (for example a couple of lines or documentation changes) there
is no need to open an issue first.
-3. Push your changes to your fork.
-4. Submit a pull request to the main repository.
-5. Follow the pull request template.
-6. Wait for review.
-7. For any follow-up work, please add new commits instead of force-pushing. This will
+3. Maintainers will triage issues and act on them. There is no such guarantee on pull requests.
+ PRs opened without being discussed in an issue MAY be ignored, and maintainers reserve the right
+ to close these PRs without justification.
+4. Push your changes to your fork.
+5. Submit a pull request to the main repository.
+6. Follow the pull request template.
+7. Wait for review.
+8. For any follow-up work, please add new commits instead of force-pushing. This will
allow the reviewer to focus on incremental changes instead of having to restart the
review process.
diff --git a/ROADMAP.md b/ROADMAP.md
index e6eccde..b71a1b5 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -8,7 +8,7 @@ The SDK tracks implementation of MCP spec components via GitHub Projects, with a
### 2.x — Stable Line (2025-11-25 spec)
-The current stable release line is **2.x** ([2.0.0](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.0), June 2026), implementing the [2025-11-25 MCP specification revision](https://modelcontextprotocol.io/specification/2025-11-25), including:
+The current stable release line is **2.x** (latest: [2.0.1](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.1), August 2026), implementing the [2025-11-25 MCP specification revision](https://modelcontextprotocol.io/specification/2025-11-25), including:
- **Spec-accurate schema**: enforced required fields with lenient wire deserialization, and a JSON compatibility foundation for forward/backward wire compatibility
- **Enhanced schemas**: JSON Schema 2020-12 validation of tool inputs and embedded schema documents (SEP-1613)
diff --git a/VERSIONING.md b/VERSIONING.md
index d8efa60..83f15d7 100644
--- a/VERSIONING.md
+++ b/VERSIONING.md
@@ -45,9 +45,9 @@ The following are **not** considered breaking:
| Line | Latest | Spec revision | Status |
|--------|--------|---------------|--------|
-| 2.0.x | [2.0.0](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.0) (2026-06-11) | 2025-11-25 | Active development |
-| 1.1.x | [1.1.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.3) (2026-05-21) | 2025-06-18 | Security patches only |
-| 0.18.x | [0.18.3](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.3) (2026-06-09) | 2025-06-18 | Security patches only |
+| 2.0.x | [2.0.1](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v2.0.1) (2026-08-19) | 2025-11-25 | Active development |
+| 1.1.x | [1.1.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.4) (2026-08-19) | 2025-06-18 | Security patches only |
+| 0.18.x | [0.18.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.4) (2026-08-19) | 2025-06-18 | Security patches only |
## Maven Coordinates
diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml
index 0a76c7a..1d111dd 100644
--- a/conformance-tests/client-jdk-http-client/pom.xml
+++ b/conformance-tests/client-jdk-http-client/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkconformance-tests
- 2.0.1
+ 2.1.0-SNAPSHOTclient-jdk-http-clientjar
@@ -21,6 +21,7 @@
+ io.modelcontextprotocol.sdk.conformance.client.jdktrue
@@ -28,7 +29,7 @@
io.modelcontextprotocol.sdkmcp
- 2.0.1
+ 2.1.0-SNAPSHOT
diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml
index 32c81ba..ccc721c 100644
--- a/conformance-tests/client-spring-http-client/pom.xml
+++ b/conformance-tests/client-spring-http-client/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkconformance-tests
- 2.0.1
+ 2.1.0-SNAPSHOTclient-spring-http-clientjar
@@ -21,6 +21,7 @@
+ io.modelcontextprotocol.sdk.conformance.client.spring174.1.02.0.0
diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml
index 8856e7d..7e411a2 100644
--- a/conformance-tests/pom.xml
+++ b/conformance-tests/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 2.0.1
+ 2.1.0-SNAPSHOTconformance-testspom
diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml
index df75a81..ce8f558 100644
--- a/conformance-tests/server-servlet/pom.xml
+++ b/conformance-tests/server-servlet/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkconformance-tests
- 2.0.1
+ 2.1.0-SNAPSHOTserver-servletjar
@@ -21,6 +21,7 @@
+ io.modelcontextprotocol.sdk.conformance.server.servlettrue
@@ -28,7 +29,7 @@
io.modelcontextprotocol.sdkmcp
- 2.0.1
+ 2.1.0-SNAPSHOT
diff --git a/docs/server.md b/docs/server.md
index 8d74359..5948f4a 100644
--- a/docs/server.md
+++ b/docs/server.md
@@ -164,7 +164,10 @@ Key features:
- Efficient bidirectional HTTP communication
- Session management for multiple client connections
- - Configurable keep-alive intervals
+ - Keep-alive pings on sessions with an open stream, enabled by default every 30 minutes
+ (`keepAliveInterval`, `null` to disable)
+ - Eviction of idle sessions — no open stream and no request for a full interval — every
+ 30 minutes by default (`sessionSweepInterval`, `null` to keep sessions until deleted)
- Security validation support
- Graceful shutdown support
@@ -441,6 +444,102 @@ var syncToolSpecification = SyncToolSpecification.builder()
`ImageContent.builder(data, mimeType)` and `AudioContent.builder(data, mimeType)` both take base64-encoded binary data. `EmbeddedResource.builder(resourceContents)` wraps either a `TextResourceContents` (for text data) or a `BlobResourceContents` (for base64-encoded binary data) — see [Reading Binary Resources](#reading-binary-resources) for the `BlobResourceContents` shape.
+### Filtering the Tool Listing per Request
+
+By default every registered tool is advertised to every caller. Over an HTTP transport you can
+vary the `tools/list` response per request — to hide tools the caller is not authorized to see,
+or to trim a large catalog down to a relevant subset — by registering one or more tool filters.
+
+The filter receives the `McpTransportContext` extracted from the current request, so it can key
+on HTTP headers, a token, a resolved principal, or anything else your
+`contextExtractor` puts there.
+
+=== "Sync"
+
+ ```java
+ McpServer.sync(transportProvider)
+ .tools(publicTool, adminTool)
+ .addToolFilter((transportContext, tool) ->
+ !tool.name().startsWith("admin-") || isAdmin(transportContext))
+ .build();
+ ```
+
+=== "Async"
+
+ ```java
+ McpServer.async(transportProvider)
+ .tools(publicTool, adminTool)
+ .addToolFilter((transportContext, tool) -> {
+ if (!tool.name().startsWith("admin-")) {
+ return Mono.just(true);
+ }
+ return isAdmin(transportContext); // Mono
+ })
+ .build();
+ ```
+
+The same `addToolFilter(...)` method is available on the stateless builders.
+
+!!! warning "Hiding a tool does not make it unreachable"
+
+ The filter controls **advertisement only**. A hidden tool called by name still executes:
+ you MUST enforce permissions in the tool's call handler. Use the filter to control what a
+ caller is told about, not what they are allowed to do.
+
+**Evaluation semantics**
+
+- The filter is consulted on **every** listing request and never cached, so the same session may
+ legitimately see different results for two successive requests carrying different credentials.
+- Registration order is preserved; only omissions happen.
+- Returning `Mono.empty()` from an async filter omits the tool. An error fails the whole listing
+ request rather than silently hiding tools: a client cannot tell a filtered-down listing from a
+ partial one, and MCP has no way to signal "this listing was incomplete, retry".
+- A filter that errors is logged server-side and reported to the client as an opaque
+ `-32603 Internal error` with no `data`. If you want the client to see a specific error, throw an
+ `McpError`, those are passed through.
+- Filters accumulate as a boolean **AND**: a tool is listed only when every registered filter accepts it, so a
+ later `addToolFilter(...)` can never widen access. Evaluation follows registration order and
+ short-circuits on the first filter that hides a tool.
+- `toolFilters(Consumer>)` hands you the list of filters registered so far, so you can
+ inspect, reorder or clear them before building — useful when filters come from several places:
+
+ ```java
+ McpServer.sync(transportProvider)
+ .addToolFilter(tenantFilter)
+ .toolFilters(filters -> filters.add(0, cheapDenyAllForAnonymousFilter))
+ .build();
+ ```
+
+- Tools are tested one at a time, so a filter that performs I/O per tool costs one round trip per
+ tool. Sync filters also run on a shared scheduler thread — not the request thread — unless
+ `immediateExecution(true)` is set, so thread-bound request state (Spring Security's
+ `SecurityContextHolder`, MDC, custom `ThreadLocal` holders) is **not visible** inside the filter.
+ For both reasons, resolve per-request state **once** in the transport's `contextExtractor`,
+ which does run on the request thread, and read only the extracted context in the filter:
+
+ ```java
+ // transport builder: one authorization lookup, on the request thread,
+ // shared by every tool tested in this request
+ var transportProvider = HttpServletStreamableServerTransportProvider.builder()
+ .contextExtractor(request -> McpTransportContext.create(
+ Map.of("perms", introspect(request.getHeader("Authorization")))))
+ // ...
+ .build();
+
+ // server builder: the filter reads only the extracted context
+ McpServer.sync(transportProvider)
+ .addToolFilter((context, tool) ->
+ ((Set) context.get("perms")).contains(tool.name()))
+ .build();
+ ```
+
+- `notifications/tools/list_changed` is **not** filtered. It is a server-initiated broadcast with
+ no request in flight, so there is no context to evaluate. A client may be told something changed
+ when its own visible set did not; it gets the correct view on its next `tools/list`. Consider disabling
+ this notification entirely when using tool filters.
+- With STDIO there is no per-request metadata, so the filter receives `McpTransportContext.EMPTY` and has nothing to key
+ on.
+
### Resource Specification
Specification of a resource with its handler function.
diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml
index 9f4f9ff..4ee9f55 100644
--- a/mcp-bom/pom.xml
+++ b/mcp-bom/pom.xml
@@ -7,7 +7,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 2.0.1
+ 2.1.0-SNAPSHOTmcp-bom
diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml
index b63b61b..79a417b 100644
--- a/mcp-core/pom.xml
+++ b/mcp-core/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdkmcp-parent
- 2.0.1
+ 2.1.0-SNAPSHOTmcp-corejar
@@ -20,6 +20,10 @@
scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
+
+ io.modelcontextprotocol.sdk.mcp.core
+
+
@@ -37,7 +41,7 @@
Bundle-Name: Bundle ${project.groupId} : ${project.artifactId}
Bundle-SymbolicName: ${project.groupId}.${project.artifactId}
Bundle-Version: ${project.version}
- Automatic-Module-Name: ${project.groupId}.${project.artifactId}
+ Automatic-Module-Name: ${module.name}
Import-Package: jakarta.*;resolution:=optional, \
*;
Service-Component: OSGI-INF/io.modelcontextprotocol.json.McpJsonDefaults.xml
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java
index 1af4eea..328566a 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java
@@ -494,9 +494,10 @@ public interface McpClient {
* calling any client operation. This allows to extract thread-locals and hand
* them over to the underlying transport.
*
- * There is no direct equivalent in {@link AsyncSpec}. To achieve the same result,
- * append {@code contextWrite(McpTransportContext.KEY, context)} to any
- * {@link McpAsyncClient} call.
+ * The supplier is invoked at subscription time, on the calling thread, and the
+ * resulting context is visible to the transport for every leg of the operation,
+ * including connections opened on other threads, such as the SSE stream started
+ * during initialization.
* @param contextProvider A supplier to create a context
* @return This builder for method chaining
*/
@@ -580,6 +581,21 @@ public interface McpClient {
*
Change notification handlers for tools, resources, and prompts
*
Custom message sampling logic
*
+ *
+ *
+ * Unlike {@link SyncSpec}, this specification has no
+ * {@code transportContextProvider}. This is deliberate: in a reactive pipeline the
+ * caller owns the Reactor context, and whatever the transport needs, such as an
+ * {@link McpTransportContext}, can be written into it directly. Write it once where
+ * the reactive chain starts, rather than at every call site, and every
+ * {@link McpAsyncClient} call downstream inherits it, including the connections
+ * opened during initialization:
To bridge thread-locals into the chain, use Reactor's context propagation
+ * support.
+ *
+ * @see SyncSpec#transportContextProvider(Supplier)
*/
class AsyncSpec {
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
index 07a8f5e..5517823 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
@@ -763,8 +763,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private Duration connectTimeout = Duration.ofSeconds(10);
- private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
- ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
+ private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2025_03_26,
+ ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
private McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientTransportAuthorizationErrorHandler.NOOP;
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
index 73522ae..60a8d7c 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java
@@ -258,16 +258,13 @@ public class StdioClientTransport implements McpClientTransport {
@Override
public Mono sendMessage(JSONRPCMessage message) {
- if (this.outboundSink.tryEmitNext(message).isSuccess()) {
- // TODO: essentially we could reschedule ourselves in some time and make
- // another attempt with the already read data but pause reading until
- // success
- // In this approach we delegate the retry and the backpressure onto the
- // caller. This might be enough for most cases.
+ try {
+ // busyLooping retries FAIL_NON_SERIALIZED under concurrent senders
+ this.outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
return Mono.empty();
}
- else {
- return Mono.error(new RuntimeException("Failed to enqueue message"));
+ catch (Sinks.EmissionException e) {
+ return Mono.error(new RuntimeException("Failed to enqueue message", e));
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java
index 756b39c..f2951d8 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java
@@ -7,18 +7,26 @@ package io.modelcontextprotocol.client.transport.customizer;
import java.net.URI;
import java.net.http.HttpRequest;
+import io.modelcontextprotocol.client.McpClient.SyncSpec;
+import io.modelcontextprotocol.common.McpTransportContext;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.util.annotation.Nullable;
-import io.modelcontextprotocol.common.McpTransportContext;
-
/**
* Customize {@link HttpRequest.Builder} before executing the request, in either SSE or
* Streamable HTTP transport.
*
* When used in a non-blocking context, implementations MUST be non-blocking.
+ *
+ * The {@link McpTransportContext} handed to {@code customize} is read from the Reactor
+ * context, under {@link McpTransportContext#KEY}, and is
+ * {@link McpTransportContext#EMPTY} when the caller wrote nothing there. Write it once
+ * where the reactive chain starts, with
+ * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}, rather than at
+ * every call site. With a synchronous client, configure
+ * {@link SyncSpec#transportContextProvider} instead.
*
* @author Daniel Garnier-Moiroux
*/
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java
index 46a2ccf..4691c13 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java
@@ -11,13 +11,28 @@ import java.util.Map;
* Context associated with the transport layer. It allows to add transport-level metadata
* for use further down the line. Specifically, it can be beneficial to extract HTTP
* request metadata for use in MCP feature implementations.
+ *
+ * The context travels in the Reactor context, under {@link #KEY}. On the server side, the
+ * transports populate it from the incoming request. On the client side, writing it is the
+ * caller's responsibility:
+ *
+ *
with a synchronous client, configure
+ * {@code McpClient.SyncSpec#transportContextProvider(Supplier)}, which is invoked on the
+ * calling thread before every operation;
+ *
with an asynchronous client, write it into the Reactor context once, where the
+ * reactive chain starts, using
+ * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}. Every client
+ * call downstream inherits it, so there is no need to repeat it at each call site.
+ *
*
* @author Dariusz Jędrzejczyk
*/
public interface McpTransportContext {
/**
- * Key for use in Reactor Context to transport the context to user land.
+ * Key for use in Reactor Context to transport the context to user land. Write the
+ * context under this key to make it visible to the transport, for example
+ * {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}.
*/
String KEY = "MCP_TRANSPORT_CONTEXT";
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java
index 7eed339..6e89ab7 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java
@@ -57,6 +57,11 @@ public interface JsonSchemaValidator {
/**
* Validates the structured content against the provided JSON schema.
+ *
+ * If {@code structuredContent} is a {@link String}, it is treated as a serialized
+ * JSON document and parsed before validation; quote embedded strings accordingly (for
+ * example {@code "\"red\""} for the JSON string value {@code red}). Any other type is
+ * converted to its JSON representation directly.
* @param schema The JSON schema to validate against.
* @param structuredContent The structured content to validate.
* @return A ValidationResponse indicating whether the validation was successful or
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java
new file mode 100644
index 0000000..d6b1858
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncListFilter.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import java.util.List;
+
+import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.util.Assert;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+/**
+ * Decide per request whether a primitive is advertised in the corresponding listing, such
+ * as {@code tools/list}.
+ *
+ * A primitive hidden by this filter is omitted from listings ONLY. It remains reachable
+ * through its own endpoint: a hidden tool called by name still executes. Permissions MUST
+ * be enforced in the primitive's handler.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @see McpSyncListFilter
+ * @see McpTransportContextExtractor
+ */
+@FunctionalInterface
+public interface McpAsyncListFilter {
+
+ /**
+ * Whether the given primitive is visible to the caller of the current request.
+ * @param transportContext transport context containing, for example, HTTP headers or
+ * a resolved principal. Should never be {@code null}, but may
+ * {@link McpTransportContext#EMPTY} for transports that carry no per-request
+ * metadata, such as STDIO.
+ * @param primitive the primitive that is a candidate for inclusion in the listing,
+ * such as {@link Tool}.
+ * @return a publisher emitting {@code true} to include the primitive in the listing,
+ * {@code false} to omit it. Completing empty omits the primitive; erroring fails the
+ * listing request.
+ */
+ Mono isVisible(McpTransportContext transportContext, T primitive);
+
+ /**
+ * Convert a potentially blocking, synchronous filter into an asynchronous one,
+ * offloading it to prevent accidental blocking of a non-blocking transport.
+ * @param filter the synchronous filter. MUST NOT be null.
+ * @param immediateExecution When true, do not offload work asynchronously. Do NOT set
+ * to true when the filter performs blocking I/O.
+ */
+ static McpAsyncListFilter fromSync(McpSyncListFilter filter, boolean immediateExecution) {
+ Assert.notNull(filter, "filter must not be null");
+ return (transportContext, primitive) -> {
+ var visible = Mono.fromCallable(() -> filter.isVisible(transportContext, primitive));
+ return immediateExecution ? visible : visible.subscribeOn(Schedulers.boundedElastic());
+ };
+ }
+
+ /**
+ * Combine multiple filters in a single AND-filter. An empty or {@code null} list
+ * makes everything visible, keeping listing on a single code path when nothing is
+ * configured.
+ * @param filters the filters to combine. May be {@code null} or empty, but MUST NOT
+ * contain {@code null} elements.
+ */
+ static McpAsyncListFilter and(List> filters) {
+ if (filters == null || filters.isEmpty()) {
+ return (transportContext, primitive) -> Mono.just(Boolean.TRUE);
+ }
+ Assert.noNullElements(filters, "filters must not contain null elements");
+ if (filters.size() == 1) {
+ return filters.get(0);
+ }
+ List> snapshot = List.copyOf(filters);
+ return (transportContext, primitive) -> Flux.fromIterable(snapshot)
+ .concatMap(filter -> filter.isVisible(transportContext, primitive).defaultIfEmpty(Boolean.FALSE))
+ .all(Boolean.TRUE::equals);
+ }
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
index ac78c4f..fc1f8a4 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java
@@ -118,6 +118,8 @@ public class McpAsyncServer {
private final ConcurrentHashMap> resourceSubscriptions = new ConcurrentHashMap<>();
+ private final McpAsyncListFilter toolFilter;
+
private List protocolVersions;
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory();
@@ -146,6 +148,7 @@ public class McpAsyncServer {
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
this.jsonSchemaValidator = jsonSchemaValidator;
this.validateToolInputs = validateToolInputs;
+ this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
Map> requestHandlers = prepareRequestHandlers();
Map notificationHandlers = prepareNotificationHandlers(features);
@@ -177,6 +180,7 @@ public class McpAsyncServer {
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
this.jsonSchemaValidator = jsonSchemaValidator;
this.validateToolInputs = validateToolInputs;
+ this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
Map> requestHandlers = prepareRequestHandlers();
Map notificationHandlers = prepareNotificationHandlers(features);
@@ -537,12 +541,32 @@ public class McpAsyncServer {
private McpRequestHandler toolsListRequestHandler() {
return (exchange, params) -> {
- List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
-
- return Mono.just(McpSchema.ListToolsResult.builder(tools).build());
+ // TODO: Implement pagination. Cursors must be computed over the filtered
+ // view, otherwise page offsets leak the number of hidden tools.
+ return Flux.fromIterable(this.tools)
+ .map(McpServerFeatures.AsyncToolSpecification::tool)
+ .filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool)
+ .onErrorResume(error -> opaqueListFilterError(tool, error)))
+ .collectList()
+ .map(tools -> McpSchema.ListToolsResult.builder(tools).build());
};
}
+ /**
+ * Report a list filter failure to the client as an opaque {@code -32603} error, so
+ * that filter internals such as identity provider hostnames or the reason a principal
+ * was rejected never leave the server. The actual cause is logged instead. An
+ * {@link McpError} is deliberate on the filter's part and passes through untouched.
+ */
+ private static Mono opaqueListFilterError(Tool tool, Throwable error) {
+ if (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
+ logger.debug("Tool list filter failed for tool '{}' with an explicit MCP error", tool.name(), error);
+ return Mono.error(mcpError);
+ }
+ logger.error("Tool list filter failed for tool '{}', failing the tools/list request", tool.name(), error);
+ return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR).message("Internal error").build());
+ }
+
private McpRequestHandler toolsCallRequestHandler() {
return (exchange, params) -> {
McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params,
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java
index a2333ae..2113fdb 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java
@@ -12,6 +12,7 @@ import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
+import java.util.function.Consumer;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonDefaults;
@@ -238,7 +239,7 @@ public interface McpServer {
public McpAsyncServer build() {
var features = new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools,
this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers,
- this.instructions);
+ this.instructions, this.toolFilters);
var jsonSchemaValidator = (this.jsonSchemaValidator != null) ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
@@ -268,7 +269,7 @@ public interface McpServer {
public McpAsyncServer build() {
var features = new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools,
this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers,
- this.instructions);
+ this.instructions, this.toolFilters);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
@@ -301,6 +302,8 @@ public interface McpServer {
boolean validateToolInputs = true;
+ final List> toolFilters = new ArrayList<>();
+
/**
* The Model Context Protocol (MCP) allows servers to expose tools that can be
* invoked by language models. Tools enable models to interact with external
@@ -440,6 +443,43 @@ public interface McpServer {
return this;
}
+ /**
+ * Adds a per-request filter deciding which tools are advertised in
+ * {@code tools/list}, for example to hide tools the caller is not authorized to
+ * see. Filters accumulate: a tool is listed only when every registered filter
+ * accepts it, so a later registration can never widen access.
+ *
+ * A hidden tool is omitted from listings only. It remains callable by name, so
+ * enforce permissions in the tool's call handler. Tools are NOT hidden from
+ * {@code notifications/tools/list_changed}, as it is a per-client context rather
+ * than per-request. Consider disabling list changed notifications entirely when
+ * using filters.
+ *
+ * @param toolFilter the filter to add, must not be null
+ * @return This builder instance for method chaining
+ * @see McpAsyncListFilter
+ */
+ public AsyncSpecification addToolFilter(McpAsyncListFilter toolFilter) {
+ Assert.notNull(toolFilter, "Tool filter must not be null");
+ this.toolFilters.add(toolFilter);
+ return this;
+ }
+
+ /**
+ * Applies the given consumer to the list of registered tool filters, allowing
+ * them to be inspected, reordered or cleared before the server is built.
+ * @param toolFiltersConsumer consumer of the mutable list of registered filters,
+ * must not be null
+ * @return This builder instance for method chaining
+ * @see #addToolFilter(McpAsyncListFilter)
+ */
+ public AsyncSpecification toolFilters(
+ Consumer>> toolFiltersConsumer) {
+ Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null");
+ toolFiltersConsumer.accept(this.toolFilters);
+ return this;
+ }
+
/**
* Sets the server capabilities that will be advertised to clients during
* connection initialization. Capabilities define what features the server
@@ -830,7 +870,7 @@ public interface McpServer {
public McpSyncServer build() {
McpServerFeatures.Sync syncFeatures = new McpServerFeatures.Sync(this.serverInfo, this.serverCapabilities,
this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions,
- this.rootsChangeHandlers, this.instructions);
+ this.rootsChangeHandlers, this.instructions, this.toolFilters);
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures,
this.immediateExecution);
@@ -865,7 +905,7 @@ public interface McpServer {
public McpSyncServer build() {
McpServerFeatures.Sync syncFeatures = new McpServerFeatures.Sync(this.serverInfo, this.serverCapabilities,
this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions,
- this.rootsChangeHandlers, this.instructions);
+ this.rootsChangeHandlers, this.instructions, this.toolFilters);
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures,
this.immediateExecution);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
@@ -900,6 +940,8 @@ public interface McpServer {
boolean validateToolInputs = true;
+ final List> toolFilters = new ArrayList<>();
+
/**
* The Model Context Protocol (MCP) allows servers to expose tools that can be
* invoked by language models. Tools enable models to interact with external
@@ -1043,6 +1085,44 @@ public interface McpServer {
return this;
}
+ /**
+ * Adds a per-request filter deciding which tools are advertised in
+ * {@code tools/list}, for example to hide tools the caller is not authorized to
+ * see. Filters accumulate: a tool is listed only when every registered filter
+ * accepts it, so a later registration can never widen access.
+ *
+ * A hidden tool is omitted from listings only. It remains callable by name, so
+ * enforce permissions in the tool's call handler. Tools are NOT hidden from
+ * {@code notifications/tools/list_changed}, as it is a per-client context rather
+ * than per-request. Consider disabling list changed notifications entirely when
+ * using filters.
+ *
+ * The filter is offloaded to a bounded elastic scheduler unless
+ * {@link #immediateExecution(boolean)} is set.
+ * @param toolFilter the filter to add, must not be null
+ * @return This builder instance for method chaining
+ * @see McpSyncListFilter
+ */
+ public SyncSpecification addToolFilter(McpSyncListFilter toolFilter) {
+ Assert.notNull(toolFilter, "Tool filter must not be null");
+ this.toolFilters.add(toolFilter);
+ return this;
+ }
+
+ /**
+ * Applies the given consumer to the list of registered tool filters, allowing
+ * them to be inspected, reordered or cleared before the server is built.
+ * @param toolFiltersConsumer consumer of the mutable list of registered filters,
+ * must not be null
+ * @return This builder instance for method chaining
+ * @see #addToolFilter(McpSyncListFilter)
+ */
+ public SyncSpecification toolFilters(Consumer>> toolFiltersConsumer) {
+ Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null");
+ toolFiltersConsumer.accept(this.toolFilters);
+ return this;
+ }
+
/**
* Sets the server capabilities that will be advertised to clients during
* connection initialization. Capabilities define what features the server
@@ -1442,6 +1522,8 @@ public interface McpServer {
boolean validateToolInputs = true;
+ final List> toolFilters = new ArrayList<>();
+
/**
* The Model Context Protocol (MCP) allows servers to expose tools that can be
* invoked by language models. Tools enable models to interact with external
@@ -1582,6 +1664,40 @@ public interface McpServer {
return this;
}
+ /**
+ * Adds a per-request filter deciding which tools are advertised in
+ * {@code tools/list}, for example to hide tools the caller is not authorized to
+ * see. Filters accumulate: a tool is listed only when every registered filter
+ * accepts it, so a later registration can never widen access.
+ *
+ * A hidden tool is omitted from listings only. It remains callable by name, so
+ * enforce permissions in the tool's call handler.
+ *
+ * @param toolFilter the filter to add, must not be null
+ * @return This builder instance for method chaining
+ * @see McpAsyncListFilter
+ */
+ public StatelessAsyncSpecification addToolFilter(McpAsyncListFilter toolFilter) {
+ Assert.notNull(toolFilter, "Tool filter must not be null");
+ this.toolFilters.add(toolFilter);
+ return this;
+ }
+
+ /**
+ * Applies the given consumer to the list of registered tool filters, allowing
+ * them to be inspected, reordered or cleared before the server is built.
+ * @param toolFiltersConsumer consumer of the mutable list of registered filters,
+ * must not be null
+ * @return This builder instance for method chaining
+ * @see #addToolFilter(McpAsyncListFilter)
+ */
+ public StatelessAsyncSpecification toolFilters(
+ Consumer>> toolFiltersConsumer) {
+ Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null");
+ toolFiltersConsumer.accept(this.toolFilters);
+ return this;
+ }
+
/**
* Sets the server capabilities that will be advertised to clients during
* connection initialization. Capabilities define what features the server
@@ -1908,7 +2024,8 @@ public interface McpServer {
public McpStatelessAsyncServer build() {
var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools,
- this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
+ this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions,
+ this.toolFilters);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
@@ -1942,6 +2059,8 @@ public interface McpServer {
boolean validateToolInputs = true;
+ final List> toolFilters = new ArrayList<>();
+
/**
* The Model Context Protocol (MCP) allows servers to expose tools that can be
* invoked by language models. Tools enable models to interact with external
@@ -2082,6 +2201,42 @@ public interface McpServer {
return this;
}
+ /**
+ * Adds a per-request filter deciding which tools are advertised in
+ * {@code tools/list}, for example to hide tools the caller is not authorized to
+ * see. Filters accumulate: a tool is listed only when every registered filter
+ * accepts it, so a later registration can never widen access.
+ *
+ * A hidden tool is omitted from listings only. It remains callable by name, so
+ * enforce permissions in the tool's call handler.
+ *
+ * The filter is offloaded to a bounded elastic scheduler unless
+ * {@link #immediateExecution(boolean)} is set.
+ * @param toolFilter the filter to add, must not be null
+ * @return This builder instance for method chaining
+ * @see McpSyncListFilter
+ */
+ public StatelessSyncSpecification addToolFilter(McpSyncListFilter toolFilter) {
+ Assert.notNull(toolFilter, "Tool filter must not be null");
+ this.toolFilters.add(toolFilter);
+ return this;
+ }
+
+ /**
+ * Applies the given consumer to the list of registered tool filters, allowing
+ * them to be inspected, reordered or cleared before the server is built.
+ * @param toolFiltersConsumer consumer of the mutable list of registered filters,
+ * must not be null
+ * @return This builder instance for method chaining
+ * @see #addToolFilter(McpSyncListFilter)
+ */
+ public StatelessSyncSpecification toolFilters(
+ Consumer>> toolFiltersConsumer) {
+ Assert.notNull(toolFiltersConsumer, "Tool filters consumer must not be null");
+ toolFiltersConsumer.accept(this.toolFilters);
+ return this;
+ }
+
/**
* Sets the server capabilities that will be advertised to clients during
* connection initialization. Capabilities define what features the server
@@ -2424,7 +2579,8 @@ public interface McpServer {
public McpStatelessSyncServer build() {
var syncFeatures = new McpStatelessServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools,
- this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions);
+ this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions,
+ this.toolFilters);
var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution);
var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator
: McpJsonDefaults.getSchemaValidator();
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
index cfa28e6..77ecbc8 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java
@@ -38,6 +38,7 @@ public class McpServerFeatures {
* @param rootsChangeConsumers The list of consumers that will be notified when the
* roots list changes
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools, Map resources,
@@ -45,7 +46,7 @@ public class McpServerFeatures {
Map prompts,
Map completions,
List, Mono>> rootsChangeConsumers,
- String instructions) {
+ String instructions, List> toolFilters) {
/**
* Create an instance and validate the arguments.
@@ -58,6 +59,7 @@ public class McpServerFeatures {
* @param rootsChangeConsumers The list of consumers that will be notified when
* the roots list changes
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools, Map resources,
@@ -65,7 +67,7 @@ public class McpServerFeatures {
Map prompts,
Map completions,
List, Mono>> rootsChangeConsumers,
- String instructions) {
+ String instructions, List> toolFilters) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -91,6 +93,7 @@ public class McpServerFeatures {
this.completions = (completions != null) ? completions : Map.of();
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : List.of();
this.instructions = instructions;
+ this.toolFilters = (toolFilters != null) ? toolFilters : List.of();
}
/**
@@ -138,7 +141,11 @@ public class McpServerFeatures {
}
return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates,
- prompts, completions, rootChangeConsumers, syncSpec.instructions());
+ prompts, completions, rootChangeConsumers, syncSpec.instructions(),
+ syncSpec.toolFilters()
+ .stream()
+ .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution))
+ .toList());
}
}
@@ -161,7 +168,8 @@ public class McpServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- List>> rootsChangeConsumers, String instructions) {
+ List>> rootsChangeConsumers, String instructions,
+ List> toolFilters) {
/**
* Create an instance and validate the arguments.
@@ -174,6 +182,7 @@ public class McpServerFeatures {
* @param rootsChangeConsumers The list of consumers that will be notified when
* the roots list changes
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -181,8 +190,8 @@ public class McpServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- List>> rootsChangeConsumers,
- String instructions) {
+ List>> rootsChangeConsumers, String instructions,
+ List> toolFilters) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -208,6 +217,7 @@ public class McpServerFeatures {
this.completions = (completions != null) ? completions : new HashMap<>();
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : new ArrayList<>();
this.instructions = instructions;
+ this.toolFilters = (toolFilters != null) ? toolFilters : List.of();
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
index 4211233..95b694e 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java
@@ -80,6 +80,8 @@ public class McpStatelessAsyncServer {
private final boolean validateToolInputs;
+ private final McpAsyncListFilter toolFilter;
+
McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper,
McpStatelessServerFeatures.Async features, Duration requestTimeout,
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator,
@@ -97,6 +99,7 @@ public class McpStatelessAsyncServer {
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
this.jsonSchemaValidator = jsonSchemaValidator;
this.validateToolInputs = validateToolInputs;
+ this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
Map> requestHandlers = new HashMap<>();
@@ -417,13 +420,32 @@ public class McpStatelessAsyncServer {
private McpStatelessRequestHandler toolsListRequestHandler() {
return (ctx, params) -> {
- List tools = this.tools.stream()
+ // TODO: Implement pagination. Cursors must be computed over the filtered
+ // view, otherwise page offsets leak the number of hidden tools.
+ return Flux.fromIterable(this.tools)
.map(McpStatelessServerFeatures.AsyncToolSpecification::tool)
- .toList();
- return Mono.just(McpSchema.ListToolsResult.builder(tools).build());
+ .filterWhen(tool -> this.toolFilter.isVisible(ctx, tool)
+ .onErrorResume(error -> opaqueListFilterError(tool, error)))
+ .collectList()
+ .map(tools -> McpSchema.ListToolsResult.builder(tools).build());
};
}
+ /**
+ * Report a list filter failure to the client as an opaque {@code -32603} error, so
+ * that filter internals such as identity provider hostnames or the reason a principal
+ * was rejected never leave the server. The actual cause is logged instead. An
+ * {@link McpError} is deliberate on the filter's part and passes through untouched.
+ */
+ private static Mono opaqueListFilterError(Tool tool, Throwable error) {
+ if (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
+ logger.debug("Tool list filter failed for tool '{}' with an explicit MCP error", tool.name(), error);
+ return Mono.error(mcpError);
+ }
+ logger.error("Tool list filter failed for tool '{}', failing the tools/list request", tool.name(), error);
+ return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR).message("Internal error").build());
+ }
+
private McpStatelessRequestHandler toolsCallRequestHandler() {
return (ctx, params) -> {
McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params,
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java
index 0c1fbfb..8ffc2a6 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java
@@ -37,6 +37,7 @@ public class McpStatelessServerFeatures {
* @param resourceTemplates The map of resource templates
* @param prompts The map of prompt specifications
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -44,7 +45,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions) {
+ String instructions, List> toolFilters) {
/**
* Create an instance and validate the arguments.
@@ -55,6 +56,7 @@ public class McpStatelessServerFeatures {
* @param resourceTemplates The map of resource templates
* @param prompts The map of prompt specifications
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -62,7 +64,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions) {
+ String instructions, List> toolFilters) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -84,6 +86,7 @@ public class McpStatelessServerFeatures {
this.prompts = (prompts != null) ? prompts : Map.of();
this.completions = (completions != null) ? completions : Map.of();
this.instructions = instructions;
+ this.toolFilters = (toolFilters != null) ? toolFilters : List.of();
}
/**
@@ -123,7 +126,11 @@ public class McpStatelessServerFeatures {
});
return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates,
- prompts, completions, syncSpec.instructions());
+ prompts, completions, syncSpec.instructions(),
+ syncSpec.toolFilters()
+ .stream()
+ .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution))
+ .toList());
}
}
@@ -137,6 +144,7 @@ public class McpStatelessServerFeatures {
* @param resourceTemplates The map of resource templates
* @param prompts The map of prompt specifications
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -144,7 +152,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions) {
+ String instructions, List> toolFilters) {
/**
* Create an instance and validate the arguments.
@@ -155,6 +163,7 @@ public class McpStatelessServerFeatures {
* @param resourceTemplates The map of resource templates
* @param prompts The map of prompt specifications
* @param instructions The server instructions text
+ * @param toolFilters The per-request filters deciding which tools are listed
*/
Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
List tools,
@@ -162,7 +171,7 @@ public class McpStatelessServerFeatures {
Map resourceTemplates,
Map prompts,
Map completions,
- String instructions) {
+ String instructions, List> toolFilters) {
Assert.notNull(serverInfo, "Server info must not be null");
@@ -187,6 +196,7 @@ public class McpStatelessServerFeatures {
this.prompts = (prompts != null) ? prompts : new HashMap<>();
this.completions = (completions != null) ? completions : new HashMap<>();
this.instructions = instructions;
+ this.toolFilters = (toolFilters != null) ? toolFilters : List.of();
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java
new file mode 100644
index 0000000..01d4d07
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncListFilter.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server;
+
+import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.spec.McpSchema;
+
+/**
+ * Decide per request whether a primitive is advertised in the corresponding listing, such
+ * as {@code tools/list}.
+ *
+ * A primitive hidden by this filter is omitted from listings ONLY. It remains reachable
+ * through its own endpoint: a hidden tool called by name still executes. Permissions MUST
+ * be enforced in the primitive's handler.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @see McpAsyncListFilter
+ * @see McpTransportContextExtractor
+ */
+@FunctionalInterface
+public interface McpSyncListFilter {
+
+ /**
+ * Whether the given primitive is visible to the caller of the current request.
+ * @param transportContext transport context containing, for example, HTTP headers or
+ * a resolved principal. Should never be {@code null}, but may
+ * {@link McpTransportContext#EMPTY} for transports that carry no per-request
+ * metadata, such as STDIO.
+ * @param primitive the primitive that is a candidate for inclusion in the listing,
+ * such as {@link McpSchema.Tool}.
+ * @return {@code true} to include the primitive in the listing, {@code false} to omit
+ * it. Throwing an exception fails the listing request.
+ */
+ boolean isVisible(McpTransportContext transportContext, T primitive);
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java
index e96403e..725a67a 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/DefaultServerTransportSecurityValidator.java
@@ -7,6 +7,7 @@ package io.modelcontextprotocol.server.transport;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import io.modelcontextprotocol.util.Assert;
@@ -22,7 +23,8 @@ import io.modelcontextprotocol.util.Assert;
* @see ServerTransportSecurityValidator
* @see ServerTransportSecurityException
*/
-public final class DefaultServerTransportSecurityValidator implements ServerTransportSecurityValidator {
+public final class DefaultServerTransportSecurityValidator
+ implements ServerTransportSecurityValidator, ServerHttpHeaderValidator {
private static final String ORIGIN_HEADER = "Origin";
@@ -47,27 +49,24 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
}
@Override
+ @Deprecated
public void validateHeaders(Map> headers) throws ServerTransportSecurityException {
- boolean missingHost = true;
- for (Map.Entry> entry : headers.entrySet()) {
- if (ORIGIN_HEADER.equalsIgnoreCase(entry.getKey())) {
- List values = entry.getValue();
- if (values == null || values.isEmpty()) {
- throw new ServerTransportSecurityException(403, "Invalid Origin header");
- }
- validateOrigin(values.get(0));
- }
- else if (HOST_HEADER.equalsIgnoreCase(entry.getKey())) {
- missingHost = false;
- List values = entry.getValue();
- if (values == null || values.isEmpty()) {
- throw new ServerTransportSecurityException(421, "Invalid Host header");
- }
- validateHost(values.get(0));
- }
+ validate(new MapHeaderAccessor(headers));
+ }
+
+ @Override
+ public void validate(HeaderAccessor headerAccessor) throws ServerTransportSecurityException {
+ List originValues = headerAccessor.getHeader(ORIGIN_HEADER);
+ if (originValues != null && !originValues.isEmpty()) {
+ validateOrigin(originValues.get(0));
}
- if (!allowedHosts.isEmpty() && missingHost) {
- throw new ServerTransportSecurityException(421, "Invalid Host header");
+
+ if (!allowedHosts.isEmpty()) {
+ List hostValues = headerAccessor.getHeader(HOST_HEADER);
+ if (hostValues == null || hostValues.isEmpty()) {
+ throw new ServerTransportSecurityException(421, "Invalid Host header");
+ }
+ validateHost(hostValues.get(0));
}
}
@@ -139,6 +138,37 @@ public final class DefaultServerTransportSecurityValidator implements ServerTran
return new Builder();
}
+ /**
+ * {@link HeaderAccessor} view over a {@code Map>}, used to
+ * bridge the deprecated {@link #validateHeaders(Map)} to
+ * {@link #validate(HeaderAccessor)}.
+ */
+ private static final class MapHeaderAccessor implements HeaderAccessor {
+
+ private final Map> headers;
+
+ private MapHeaderAccessor(Map> headers) {
+ this.headers = headers;
+ }
+
+ @Override
+ public List getHeader(String name) {
+ return headers.entrySet()
+ .stream()
+ .filter(entry -> entry.getKey().equalsIgnoreCase(name))
+ .map(Map.Entry::getValue)
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(List.of());
+ }
+
+ @Override
+ public List getHeaderNames() {
+ return List.copyOf(headers.keySet());
+ }
+
+ }
+
/**
* Builder for creating instances of {@link DefaultServerTransportSecurityValidator}.
*/
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java
new file mode 100644
index 0000000..efed4a7
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HeaderAccessor.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+import java.util.List;
+
+/**
+ * Abstraction for accessing HTTP headers from an incoming request. Implementations should
+ * provide case-insensitive header name lookups (e.g., when backed by
+ * {@code HttpServletRequest}).
+ *
+ * @author Neeraj Bhatt
+ * @since 2.1.0
+ * @see ServerHttpHeaderValidator
+ */
+public interface HeaderAccessor {
+
+ /**
+ * Returns the values of the specified header, or an empty list if the header is not
+ * present.
+ * @param name the header name (case-insensitive)
+ * @return the list of header values, never {@code null}
+ */
+ List getHeader(String name);
+
+ /**
+ * Returns all header names present in the request.
+ * @return the list of header names, never {@code null}
+ */
+ List getHeaderNames();
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java
new file mode 100644
index 0000000..a06261c
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletHeaderAccessor.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+import java.util.Collections;
+import java.util.List;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+/**
+ * {@link HeaderAccessor} implementation backed by an {@link HttpServletRequest}. Header
+ * name lookups are case-insensitive as per the Servlet specification.
+ *
+ *
+ * For internal use only.
+ *
+ * @author Neeraj Bhatt
+ * @since 2.1.0
+ * @see HeaderAccessor
+ */
+final class HttpServletHeaderAccessor implements HeaderAccessor {
+
+ private final HttpServletRequest request;
+
+ HttpServletHeaderAccessor(HttpServletRequest request) {
+ this.request = request;
+ }
+
+ @Override
+ public List getHeader(String name) {
+ return Collections.list(this.request.getHeaders(name));
+ }
+
+ @Override
+ public List getHeaderNames() {
+ return Collections.list(this.request.getHeaderNames());
+ }
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java
index 721bdf1..5901c38 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletRequestUtils.java
@@ -8,11 +8,6 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
-import java.util.Collections;
-import java.util.Enumeration;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
@@ -26,21 +21,6 @@ final class HttpServletRequestUtils {
private HttpServletRequestUtils() {
}
- /**
- * Extracts all headers from the HTTP request into a map.
- * @param request The HTTP servlet request
- * @return A map of header names to their values
- */
- static Map> extractHeaders(HttpServletRequest request) {
- Map> headers = new HashMap<>();
- Enumeration names = request.getHeaderNames();
- while (names.hasMoreElements()) {
- String name = names.nextElement();
- headers.put(name, Collections.list(request.getHeaders(name)));
- }
- return headers;
- }
-
/**
* Reads the request body, decoded using the request's character encoding (or UTF-8 if
* not specified), while bounding the number of bytes read.
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
index 05dd862..a14271a 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
@@ -161,7 +161,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
/**
* Security validator for validating HTTP requests.
*/
- private final ServerTransportSecurityValidator securityValidator;
+ private final ServerHttpHeaderValidator httpHeaderValidator;
/**
* Creates a new HttpServletSseServerTransportProvider instance with a custom SSE
@@ -174,20 +174,20 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* @param keepAliveInterval The interval for keep-alive pings, or null to disable
* keep-alive functionality
* @param contextExtractor The extractor for transport context from the request.
- * @param securityValidator The security validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header validator for validating HTTP requests.
* @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
* positive.
*/
private HttpServletSseServerTransportProvider(McpJsonMapper jsonMapper, String baseUrl, String messageEndpoint,
String sseEndpoint, Duration keepAliveInterval,
McpTransportContextExtractor contextExtractor,
- ServerTransportSecurityValidator securityValidator, int requestMaxSize) {
+ ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(messageEndpoint, "messageEndpoint must not be null");
Assert.notNull(sseEndpoint, "sseEndpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
- Assert.notNull(securityValidator, "Security validator must not be null");
+ Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
@@ -195,7 +195,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
this.messageEndpoint = messageEndpoint;
this.sseEndpoint = sseEndpoint;
this.contextExtractor = contextExtractor;
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = httpHeaderValidator;
this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) {
@@ -293,8 +293,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -371,8 +370,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -619,7 +617,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
private Duration keepAliveInterval;
- private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
@@ -702,10 +700,25 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
* @param securityValidator The security validator to use. Must not be null.
* @return This builder instance
* @throws IllegalArgumentException if securityValidator is null
+ * @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)}
+ * instead.
*/
+ @Deprecated
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null");
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator);
+ return this;
+ }
+
+ /**
+ * Sets the HTTP header validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header validator to use. Must not be null.
+ * @return This builder instance
+ * @throws IllegalArgumentException if httpHeaderValidator is null
+ */
+ public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) {
+ Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
+ this.httpHeaderValidator = httpHeaderValidator;
return this;
}
@@ -734,7 +747,7 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
}
return new HttpServletSseServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, baseUrl, messageEndpoint,
- sseEndpoint, keepAliveInterval, contextExtractor, securityValidator, requestMaxSize);
+ sseEndpoint, keepAliveInterval, contextExtractor, httpHeaderValidator, requestMaxSize);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
index 54f0ac0..9cd0d04 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStatelessServerTransport.java
@@ -7,7 +7,6 @@ package io.modelcontextprotocol.server.transport;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.List;
-import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -68,7 +67,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
/**
* Security validator for validating HTTP requests.
*/
- private final ServerTransportSecurityValidator securityValidator;
+ private final ServerHttpHeaderValidator httpHeaderValidator;
/**
* Maximum size, in bytes, of a single request body accepted by this transport.
@@ -82,24 +81,24 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
* @param mcpEndpoint The endpoint URI where clients should send their JSON-RPC
* messages.
* @param contextExtractor The extractor for transport context from the request.
- * @param securityValidator The security validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header validator for validating HTTP requests.
* @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
* positive.
* @throws IllegalArgumentException if any parameter is null
*/
private HttpServletStatelessServerTransport(McpJsonMapper jsonMapper, String mcpEndpoint,
McpTransportContextExtractor contextExtractor,
- ServerTransportSecurityValidator securityValidator, int requestMaxSize) {
+ ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize) {
Assert.notNull(jsonMapper, "jsonMapper must not be null");
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null");
- Assert.notNull(securityValidator, "Security validator must not be null");
+ Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = httpHeaderValidator;
this.requestMaxSize = requestMaxSize;
}
@@ -162,8 +161,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -300,7 +298,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
private McpTransportContextExtractor contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY;
- private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
@@ -354,10 +352,25 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
* @param securityValidator The security validator to use. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if securityValidator is null
+ * @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)}
+ * instead.
*/
+ @Deprecated
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null");
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator);
+ return this;
+ }
+
+ /**
+ * Sets the HTTP header validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header validator to use. Must not be null.
+ * @return this builder instance
+ * @throws IllegalArgumentException if httpHeaderValidator is null
+ */
+ public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) {
+ Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
+ this.httpHeaderValidator = httpHeaderValidator;
return this;
}
@@ -384,7 +397,7 @@ public class HttpServletStatelessServerTransport extends HttpServlet implements
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new HttpServletStatelessServerTransport(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, contextExtractor,
- securityValidator, requestMaxSize);
+ httpHeaderValidator, requestMaxSize);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
index 324a2ec..5f883cb 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
@@ -9,8 +9,9 @@ import java.io.PrintWriter;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
-import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import io.modelcontextprotocol.common.McpTransportContext;
@@ -21,12 +22,17 @@ import io.modelcontextprotocol.server.McpTransportContextExtractor;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.McpSession;
import io.modelcontextprotocol.spec.McpStreamableServerSession;
import io.modelcontextprotocol.spec.McpStreamableServerTransport;
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
+import io.modelcontextprotocol.spec.McpTransportException;
+import io.modelcontextprotocol.spec.ProtocolVersions;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.KeepAliveScheduler;
import jakarta.servlet.AsyncContext;
+import jakarta.servlet.AsyncEvent;
+import jakarta.servlet.AsyncListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
@@ -34,8 +40,10 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
/**
* Server-side implementation of the Model Context Protocol (MCP) streamable transport
@@ -52,6 +60,7 @@ import reactor.core.publisher.Mono;
* @author Zachary German
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
+ * @author Daniel Garnier-Moiroux
* @see McpStreamableServerTransportProvider
* @see HttpServlet
*/
@@ -114,6 +123,14 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
*/
private final ConcurrentHashMap sessions = new ConcurrentHashMap<>();
+ /**
+ * IDs of the sessions which received a request since the last sweep. The set is
+ * swapped for an empty one on every sweep, so it only ever holds the activity of the
+ * current interval. Only populated when a sweeper runs, as nothing would ever swap it
+ * otherwise.
+ */
+ private final AtomicReference> activeSessions = new AtomicReference<>(ConcurrentHashMap.newKeySet());
+
private McpTransportContextExtractor contextExtractor;
/**
@@ -127,10 +144,18 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
*/
private KeepAliveScheduler keepAliveScheduler;
+ /**
+ * Periodic eviction of the sessions no client came back to. {@code null} if no
+ * sessionSweepInterval is set, in which case sessions are never reclaimed.
+ */
+ private final Disposable sessionSweeper;
+
+ private static Duration SESSION_SWEEP_TIMEOUT = Duration.ofSeconds(30);
+
/**
* Security validator for validating HTTP requests.
*/
- private final ServerTransportSecurityValidator securityValidator;
+ private final ServerHttpHeaderValidator httpHeaderValidator;
/**
* Constructs a new HttpServletStreamableServerTransportProvider instance.
@@ -142,38 +167,122 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* @param contextExtractor The extractor for transport context from the request.
* @param keepAliveInterval The interval for keep-alive pings. If null, no keep-alive
* will be scheduled.
- * @param securityValidator The security validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header validator for validating HTTP requests.
* @param requestMaxSize The maximum size, in bytes, of a single request body. Must be
* positive.
+ * @param sessionSweepInterval The interval at which idle sessions are evicted. If
+ * null, no sweeping will be scheduled.
* @throws IllegalArgumentException if any parameter is null
*/
private HttpServletStreamableServerTransportProvider(McpJsonMapper jsonMapper, String mcpEndpoint,
boolean disallowDelete, McpTransportContextExtractor contextExtractor,
- Duration keepAliveInterval, ServerTransportSecurityValidator securityValidator, int requestMaxSize) {
+ Duration keepAliveInterval, ServerHttpHeaderValidator httpHeaderValidator, int requestMaxSize,
+ Duration sessionSweepInterval) {
Assert.notNull(jsonMapper, "JsonMapper must not be null");
Assert.notNull(mcpEndpoint, "MCP endpoint must not be null");
Assert.notNull(contextExtractor, "Context extractor must not be null");
- Assert.notNull(securityValidator, "Security validator must not be null");
+ Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
Assert.isTrue(requestMaxSize > 0, "requestMaxSize must be positive");
this.jsonMapper = jsonMapper;
this.mcpEndpoint = mcpEndpoint;
this.disallowDelete = disallowDelete;
this.contextExtractor = contextExtractor;
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = httpHeaderValidator;
this.requestMaxSize = requestMaxSize;
if (keepAliveInterval != null) {
- this.keepAliveScheduler = KeepAliveScheduler
- .builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(sessions.values()))
+ this.keepAliveScheduler = KeepAliveScheduler.builder(this::sessionsToPing)
.initialDelay(keepAliveInterval)
.interval(keepAliveInterval)
+ .onPingFailure(session -> {
+ // The stream the ping was written to is dead. The session survives:
+ // the client may reconnect to it, and the idle timeout reclaims it if
+ // it never does.
+ if (session instanceof McpStreamableServerSession streamableSession) {
+ streamableSession.releaseListeningStream();
+ }
+ })
.build();
this.keepAliveScheduler.start();
}
+ this.sessionSweeper = sessionSweepInterval == null ? null : startSessionSweeper(sessionSweepInterval);
+ }
+
+ /**
+ * Schedules the periodic eviction of idle sessions.
+ * @param sessionSweepInterval the interval between two sweeps
+ * @return the handle on the scheduled sweeps, to be disposed when the transport shuts
+ * down
+ */
+ private Disposable startSessionSweeper(Duration sessionSweepInterval) {
+ return Flux
+ .interval(sessionSweepInterval, sessionSweepInterval,
+ Schedulers.newSingle("streamable-http-server-transport-session-sweeper"))
+ .concatMap(
+ tick -> sweepSessions().doOnError(e -> logger.error("Session sweep failed", e)).onErrorComplete())
+ .subscribe(next -> {
+ }, error -> logger.error("Session sweeper error", error));
+ }
+
+ /**
+ * Evicts the sessions no client is using anymore. A session is kept if it holds an
+ * open stream, which a client can legitimately sit on without ever writing to it, or
+ * if it received a request during the interval which just elapsed. Anything else is a
+ * session whose client went away without deleting it: the protocol lets a client
+ * reconnect to a session, so nothing else ever reclaims it.
+ * @return a Mono completing once every evicted session has been closed
+ */
+ private Mono sweepSessions() {
+ if (this.isClosing) {
+ return Mono.empty();
+ }
+ Set active = this.activeSessions.getAndSet(ConcurrentHashMap.newKeySet());
+ return Flux.fromIterable(this.sessions.values()).filter(session -> {
+ if (session.hasOpenStream() || active.contains(session.getId())) {
+ return false;
+ }
+ return this.sessions.remove(session.getId(), session);
+ }).flatMap(session -> {
+ logger.debug("Evicting idle session {}", session.getId());
+ return session.closeGracefully()
+ .timeout(SESSION_SWEEP_TIMEOUT)
+ .doOnError(e -> logger.warn("Failed to close idle session {}: {}", session.getId(), e.getMessage()))
+ .onErrorComplete();
+ }).then();
+ }
+
+ /**
+ * Records that the given session is being used, so that the next sweep does not
+ * mistake it for a session whose client is gone.
+ * @param sessionId the session the current request belongs to
+ */
+ private void markSessionActive(String sessionId) {
+ if (this.sessionSweeper == null) {
+ // No sweep ever swaps the set of active sessions, so recording activity would
+ // only accumulate the ID of every session which ever issued a request
+ return;
+ }
+ this.activeSessions.get().add(sessionId);
+ }
+
+ /**
+ * Returns the sessions a keep-alive ping can be sent to, that is the sessions having
+ * a listening stream. A session without one, e.g. a client which only ever issues
+ * POST requests, has nothing to write a ping to: pinging it would fail on every
+ * interval without ever telling us anything about the client being alive.
+ * @return the sessions to ping
+ */
+ private Flux sessionsToPing() {
+ if (this.isClosing) {
+ return Flux.empty();
+ }
+ return Flux.fromIterable(this.sessions.values())
+ .filter(McpStreamableServerSession::hasListeningStream)
+ .cast(McpSession.class);
}
@Override
@@ -248,6 +357,9 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
if (this.keepAliveScheduler != null) {
this.keepAliveScheduler.shutdown();
}
+ if (this.sessionSweeper != null) {
+ this.sessionSweeper.dispose();
+ }
});
}
@@ -274,8 +386,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -308,6 +419,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
+ this.markSessionActive(sessionId);
logger.debug("Handling GET request for session: {}", sessionId);
@@ -325,61 +437,21 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
sessionId, asyncContext, response.getWriter());
- // Check if this is a replay request
- if (request.getHeader(HttpHeaders.LAST_EVENT_ID) != null) {
- String lastId = request.getHeader(HttpHeaders.LAST_EVENT_ID);
-
- try {
- session.replay(lastId)
- .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
- .toIterable()
- .forEach(message -> {
- try {
- sessionTransport.sendMessage(message)
- .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
- .block();
- }
- catch (Exception e) {
- logger.error("Failed to replay message: {}", e.getMessage());
- asyncContext.complete();
- }
- });
- }
- catch (Exception e) {
- logger.error("Failed to replay messages: {}", e.getMessage());
- asyncContext.complete();
- }
+ // Replay the messages the client missed while its stream was broken
+ String lastEventId = request.getHeader(HttpHeaders.LAST_EVENT_ID);
+ if (lastEventId != null
+ && !this.tryReplayMissedMessages(session, lastEventId, sessionTransport, transportContext)) {
+ // The replay failed and already closed the transport
+ return;
}
- else {
- // Establish new listening stream
- McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session
- .listeningStream(sessionTransport);
- asyncContext.addListener(new jakarta.servlet.AsyncListener() {
- @Override
- public void onComplete(jakarta.servlet.AsyncEvent event) throws IOException {
- logger.debug("SSE connection completed for session: {}", sessionId);
- listeningStream.close();
- }
+ // Establish the listening stream. Resumed streams are registered too, so
+ // that the session keeps delivering messages to the reconnected client and
+ // the async context is completed once the client goes away.
+ McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session
+ .listeningStream(sessionTransport);
- @Override
- public void onTimeout(jakarta.servlet.AsyncEvent event) throws IOException {
- logger.debug("SSE connection timed out for session: {}", sessionId);
- listeningStream.close();
- }
-
- @Override
- public void onError(jakarta.servlet.AsyncEvent event) throws IOException {
- logger.debug("SSE connection error for session: {}", sessionId);
- listeningStream.close();
- }
-
- @Override
- public void onStartAsync(jakarta.servlet.AsyncEvent event) throws IOException {
- // No action needed
- }
- });
- }
+ registerAsyncLifecycle(asyncContext, sessionId, listeningStream::releaseTransport);
}
catch (Exception e) {
logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage());
@@ -414,8 +486,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -454,6 +525,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
});
McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory
.startSession(initializeRequest);
+ this.markSessionActive(init.session().getId());
this.sessions.put(init.session().getId(), init.session());
try {
@@ -505,6 +577,8 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
return;
}
+ this.markSessionActive(sessionId);
+
if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) {
session.accept(jsonrpcResponse)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
@@ -530,8 +604,16 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
HttpServletStreamableMcpSessionTransport sessionTransport = new HttpServletStreamableMcpSessionTransport(
sessionId, asyncContext, response.getWriter());
+ // The listener is given the stream rather than its transport, so that the
+ // end of the connection detaches the stream from the session instead of
+ // only dropping the socket: a stream outliving its connection keeps the
+ // session looking busy and spares it from the sweeper
+ McpStreamableServerSession.McpStreamableServerSessionStream responseStream = session
+ .responseStream(sessionTransport);
+ registerAsyncLifecycle(asyncContext, sessionId, responseStream::releaseTransport);
+
try {
- session.responseStream(jsonrpcRequest, sessionTransport)
+ responseStream.handle(jsonrpcRequest)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
}
@@ -593,8 +675,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
try {
- Map> headers = HttpServletRequestUtils.extractHeaders(request);
- this.securityValidator.validateHeaders(headers);
+ this.httpHeaderValidator.validate(new HttpServletHeaderAccessor(request));
}
catch (ServerTransportSecurityException e) {
response.sendError(e.getStatusCode(), e.getMessage());
@@ -686,6 +767,70 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
super.destroy();
}
+ /**
+ * Replays the messages the client missed while its SSE stream was broken.
+ * @param session the session the client is resuming
+ * @param lastEventId the ID of the last event received by the client
+ * @param sessionTransport the transport of the resumed SSE stream
+ * @param transportContext the context extracted from the request
+ * @return {@code true} if the replay completed, {@code false} if it failed, in which
+ * case the transport has been closed
+ */
+ private static boolean tryReplayMissedMessages(McpStreamableServerSession session, String lastEventId,
+ McpStreamableServerTransport sessionTransport, McpTransportContext transportContext) {
+ try {
+ for (McpSchema.JSONRPCMessage message : session.replay(lastEventId)
+ .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
+ .toIterable()) {
+ sessionTransport.sendMessage(message)
+ .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
+ .block();
+ }
+ return true;
+ }
+ catch (Exception e) {
+ logger.error("Failed to replay messages for session {}: {}", session.getId(), e.getMessage());
+ sessionTransport.close();
+ return false;
+ }
+ }
+
+ /**
+ * Registers a listener releasing the SSE stream carried by the given asynchronous
+ * request once the container is done with it, whether the client went away, the
+ * request timed out or it errored. Without this, the connection is left open, holding
+ * on to a socket and a container thread, until the process restarts.
+ * @param asyncContext the asynchronous context of the SSE request
+ * @param sessionId the session the stream belongs to
+ * @param onConnectionEnd the action releasing the stream
+ */
+ private static void registerAsyncLifecycle(AsyncContext asyncContext, String sessionId, Runnable onConnectionEnd) {
+ asyncContext.addListener(new AsyncListener() {
+ @Override
+ public void onComplete(AsyncEvent event) throws IOException {
+ logger.debug("SSE connection completed for session: {}", sessionId);
+ onConnectionEnd.run();
+ }
+
+ @Override
+ public void onTimeout(AsyncEvent event) throws IOException {
+ logger.debug("SSE connection timed out for session: {}", sessionId);
+ onConnectionEnd.run();
+ }
+
+ @Override
+ public void onError(AsyncEvent event) throws IOException {
+ logger.debug("SSE connection error for session: {}", sessionId);
+ onConnectionEnd.run();
+ }
+
+ @Override
+ public void onStartAsync(AsyncEvent event) throws IOException {
+ // No action needed
+ }
+ });
+ }
+
/**
* Implementation of McpStreamableServerTransport for HttpServlet SSE sessions. This
* class handles the transport-level communication for a specific client session.
@@ -695,7 +840,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* underlying PrintWriter to prevent race conditions when multiple threads attempt to
* send messages concurrently.
*/
-
private class HttpServletStreamableMcpSessionTransport implements McpStreamableServerTransport {
private final String sessionId;
@@ -759,9 +903,17 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId);
}
catch (Exception e) {
+ // The connection is gone, the session is not: the client may come
+ // back for it, and the sweeper reclaims it if it never does
logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage());
- HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId);
- this.asyncContext.complete();
+ this.close();
+ // Surfaced to the caller rather than swallowed: whoever is writing to
+ // this stream has to learn that it no longer leads anywhere, or it
+ // keeps producing messages for a client which is gone. A request
+ // being streamed a response would never finish, holding on to the
+ // container thread which has to be given back before the end of the
+ // connection can be acted upon.
+ throw new McpTransportException("Failed to send message to session " + this.sessionId, e);
}
finally {
lock.unlock();
@@ -805,8 +957,6 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
this.closed = true;
-
- // HttpServletStreamableServerTransportProvider.this.sessions.remove(this.sessionId);
this.asyncContext.complete();
logger.debug("Successfully completed async context for session {}", sessionId);
}
@@ -820,6 +970,12 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
}
+ @Override
+ public List protocolVersions() {
+ return List.of(ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18,
+ ProtocolVersions.MCP_2025_11_25);
+ }
+
public static Builder builder() {
return new Builder();
}
@@ -839,12 +995,14 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
private McpTransportContextExtractor contextExtractor = (
serverRequest) -> McpTransportContext.EMPTY;
- private Duration keepAliveInterval;
+ private Duration keepAliveInterval = Duration.ofMinutes(30);
- private ServerTransportSecurityValidator securityValidator = ServerTransportSecurityValidator.NOOP;
+ private ServerHttpHeaderValidator httpHeaderValidator = ServerHttpHeaderValidator.NOOP;
private int requestMaxSize = DEFAULT_REQUEST_MAX_SIZE;
+ private Duration sessionSweepInterval = Duration.ofMinutes(30);
+
/**
* Sets the JsonMapper to use for JSON serialization/deserialization of MCP
* messages.
@@ -896,7 +1054,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* Sets the keep-alive interval for the transport. If set, a keep-alive scheduler
* will be activated to periodically ping active sessions.
* @param keepAliveInterval The interval for keep-alive pings. If null, no
- * keep-alive will be scheduled.
+ * keep-alive will be scheduled. Defaults to 30 minutes.
* @return this builder instance
*/
public Builder keepAliveInterval(Duration keepAliveInterval) {
@@ -909,10 +1067,25 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
* @param securityValidator The security validator to use. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if securityValidator is null
+ * @deprecated Use {@link #httpHeaderValidator(ServerHttpHeaderValidator)}
+ * instead.
*/
+ @Deprecated
public Builder securityValidator(ServerTransportSecurityValidator securityValidator) {
Assert.notNull(securityValidator, "Security validator must not be null");
- this.securityValidator = securityValidator;
+ this.httpHeaderValidator = ServerTransportSecurityValidator.toHttpHeaderValidator(securityValidator);
+ return this;
+ }
+
+ /**
+ * Sets the HTTP header validator for validating HTTP requests.
+ * @param httpHeaderValidator The HTTP header validator to use. Must not be null.
+ * @return this builder instance
+ * @throws IllegalArgumentException if httpHeaderValidator is null
+ */
+ public Builder httpHeaderValidator(ServerHttpHeaderValidator httpHeaderValidator) {
+ Assert.notNull(httpHeaderValidator, "HTTP header validator must not be null");
+ this.httpHeaderValidator = httpHeaderValidator;
return this;
}
@@ -929,6 +1102,22 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
return this;
}
+ /**
+ * Sets the interval at which idle sessions are evicted. A session is idle once it
+ * holds no open stream and has received no request for a full interval, which is
+ * how a session whose client went away without deleting it looks. Nothing else
+ * reclaims those sessions, as the protocol lets a client reconnect to a session
+ * it has been disconnected from.
+ * @param sessionSweepInterval The interval between two sweeps. If null, no
+ * sweeping will be scheduled and sessions are kept until they are deleted or the
+ * server shuts down. Defaults to 30 minutes.
+ * @return this builder instance
+ */
+ public Builder sessionSweepInterval(Duration sessionSweepInterval) {
+ this.sessionSweepInterval = sessionSweepInterval;
+ return this;
+ }
+
/**
* Builds a new instance of {@link HttpServletStreamableServerTransportProvider}
* with the configured settings.
@@ -939,7 +1128,7 @@ public class HttpServletStreamableServerTransportProvider extends HttpServlet
Assert.notNull(this.mcpEndpoint, "MCP endpoint must be set");
return new HttpServletStreamableServerTransportProvider(
jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, mcpEndpoint, disallowDelete,
- contextExtractor, keepAliveInterval, securityValidator, requestMaxSize);
+ contextExtractor, keepAliveInterval, httpHeaderValidator, requestMaxSize, sessionSweepInterval);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java
new file mode 100644
index 0000000..6f2a3dc
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerHttpHeaderValidator.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.server.transport;
+
+/**
+ * Validates HTTP request headers in server transports.
+ *
+ * @author Neeraj Bhatt
+ * @see HeaderAccessor
+ * @see ServerTransportSecurityException
+ */
+@FunctionalInterface
+public interface ServerHttpHeaderValidator {
+
+ /**
+ * A no-op validator that accepts all requests without validation.
+ */
+ ServerHttpHeaderValidator NOOP = headerAccessor -> {
+ };
+
+ /**
+ * Validates the HTTP headers from an incoming request.
+ * @param headerAccessor provides access to request headers
+ * @throws ServerTransportSecurityException if validation fails
+ */
+ void validate(HeaderAccessor headerAccessor) throws ServerTransportSecurityException;
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java
index ce80593..023f7f3 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/ServerTransportSecurityValidator.java
@@ -6,6 +6,8 @@ package io.modelcontextprotocol.server.transport;
import java.util.List;
import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
/**
* Interface for validating HTTP requests in server transports. Implementations can
@@ -13,9 +15,12 @@ import java.util.Map;
* to the MCP specification.
*
* @author Daniel Garnier-Moiroux
- * @see DefaultServerTransportSecurityValidator
+ * @see ServerHttpHeaderValidator
* @see ServerTransportSecurityException
+ * @deprecated Use {@link ServerHttpHeaderValidator} instead. This interface will be
+ * removed in a future major version.
*/
+@Deprecated
@FunctionalInterface
public interface ServerTransportSecurityValidator {
@@ -25,6 +30,20 @@ public interface ServerTransportSecurityValidator {
ServerTransportSecurityValidator NOOP = headers -> {
};
+ /**
+ * Adapts a legacy map-based validator to a header accessor validator.
+ * @param validator the legacy validator to adapt
+ * @return a header accessor validator delegating to {@code validator}
+ */
+ static ServerHttpHeaderValidator toHttpHeaderValidator(ServerTransportSecurityValidator validator) {
+ return accessor -> {
+ var collectedHeaders = accessor.getHeaderNames()
+ .stream()
+ .collect(Collectors.toUnmodifiableMap(Function.identity(), accessor::getHeader));
+ validator.validateHeaders(collectedHeaders);
+ };
+ }
+
/**
* Validates the HTTP headers from an incoming request.
* @param headers A map of header names to their values (multi-valued headers
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java
index cc51233..8370e41 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java
@@ -10,13 +10,13 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import io.modelcontextprotocol.json.TypeRef;
-import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
import io.modelcontextprotocol.spec.McpServerSession;
@@ -175,11 +175,12 @@ public class StdioServerTransportProvider implements McpServerTransportProvider
public Mono sendMessage(McpSchema.JSONRPCMessage message) {
return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> {
- if (outboundSink.tryEmitNext(message).isSuccess()) {
+ try {
+ outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
return Mono.empty();
}
- else {
- return Mono.error(new RuntimeException("Failed to enqueue message"));
+ catch (Sinks.EmissionException e) {
+ return Mono.error(new RuntimeException("Failed to enqueue message", e));
}
}));
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
index e7fac7b..5ac0863 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java
@@ -1,29 +1,31 @@
/*
- * Copyright 2024-2025 the original author or authors.
+ * Copyright 2024-2026 the original author or authors.
*/
package io.modelcontextprotocol.spec;
import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Predicate;
import java.util.function.Supplier;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import io.modelcontextprotocol.json.TypeRef;
-
import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.server.McpAsyncServerExchange;
import io.modelcontextprotocol.server.McpNotificationHandler;
import io.modelcontextprotocol.server.McpRequestHandler;
import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
import io.modelcontextprotocol.util.Assert;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.MonoSink;
@@ -36,12 +38,26 @@ import reactor.core.publisher.MonoSink;
*
* @author Dariusz Jędrzejczyk
* @author Yanming Zhou
+ * @author Daniel Garnier-Moiroux
*/
public class McpStreamableServerSession implements McpLoggableSession {
private static final Logger logger = LoggerFactory.getLogger(McpStreamableServerSession.class);
- private final ConcurrentHashMap