Add filtering for MCP tool list (#1108)

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-08-31 10:19:49 +02:00 • committed by GitHub
commit 2293cf70b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1386 additions and 23 deletions

View file

@ -441,6 +441,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<Boolean>
})
.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<List<...>>)` 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<String>) 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.

View file

@ -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}.
* <p>
* 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<T> {
/**
* 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<Boolean> 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 <T> McpAsyncListFilter<T> fromSync(McpSyncListFilter<T> 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 <T> McpAsyncListFilter<T> and(List<McpAsyncListFilter<T>> 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<McpAsyncListFilter<T>> snapshot = List.copyOf(filters);
return (transportContext, primitive) -> Flux.fromIterable(snapshot)
.concatMap(filter -> filter.isVisible(transportContext, primitive).defaultIfEmpty(Boolean.FALSE))
.all(Boolean.TRUE::equals);
}
}

View file

@ -118,6 +118,8 @@ public class McpAsyncServer {
private final ConcurrentHashMap<String, Set<String>> resourceSubscriptions = new ConcurrentHashMap<>();
private final McpAsyncListFilter<McpSchema.Tool> toolFilter;
private List<String> 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<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
Map<String, McpNotificationHandler> 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<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
@ -537,12 +541,32 @@ public class McpAsyncServer {
private McpRequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
return (exchange, params) -> {
List<Tool> 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<Boolean> 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<CallToolResult> toolsCallRequestHandler() {
return (exchange, params) -> {
McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params,

View file

@ -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<McpAsyncListFilter<McpSchema.Tool>> 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.
* <p>
* 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.
* <p>
* @param toolFilter the filter to add, must not be null
* @return This builder instance for method chaining
* @see McpAsyncListFilter
*/
public AsyncSpecification<S> addToolFilter(McpAsyncListFilter<McpSchema.Tool> 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<S> toolFilters(
Consumer<List<McpAsyncListFilter<McpSchema.Tool>>> 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<McpSyncListFilter<McpSchema.Tool>> 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.
* <p>
* 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.
* <p>
* 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<S> addToolFilter(McpSyncListFilter<McpSchema.Tool> 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<S> toolFilters(Consumer<List<McpSyncListFilter<McpSchema.Tool>>> 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<McpAsyncListFilter<McpSchema.Tool>> 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.
* <p>
* A hidden tool is omitted from listings only. It remains callable by name, so
* enforce permissions in the tool's call handler.
* <p>
* @param toolFilter the filter to add, must not be null
* @return This builder instance for method chaining
* @see McpAsyncListFilter
*/
public StatelessAsyncSpecification addToolFilter(McpAsyncListFilter<McpSchema.Tool> 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<List<McpAsyncListFilter<McpSchema.Tool>>> 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<McpSyncListFilter<McpSchema.Tool>> 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.
* <p>
* A hidden tool is omitted from listings only. It remains callable by name, so
* enforce permissions in the tool's call handler.
* <p>
* 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<McpSchema.Tool> 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<List<McpSyncListFilter<McpSchema.Tool>>> 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();

View file

@ -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<McpServerFeatures.AsyncToolSpecification> tools, Map<String, AsyncResourceSpecification> resources,
@ -45,7 +46,7 @@ public class McpServerFeatures {
Map<String, McpServerFeatures.AsyncPromptSpecification> prompts,
Map<McpSchema.CompleteReference, McpServerFeatures.AsyncCompletionSpecification> completions,
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers,
String instructions) {
String instructions, List<McpAsyncListFilter<McpSchema.Tool>> 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<McpServerFeatures.AsyncToolSpecification> tools, Map<String, AsyncResourceSpecification> resources,
@ -65,7 +67,7 @@ public class McpServerFeatures {
Map<String, McpServerFeatures.AsyncPromptSpecification> prompts,
Map<McpSchema.CompleteReference, McpServerFeatures.AsyncCompletionSpecification> completions,
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers,
String instructions) {
String instructions, List<McpAsyncListFilter<McpSchema.Tool>> 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<String, McpServerFeatures.SyncResourceTemplateSpecification> resourceTemplates,
Map<String, McpServerFeatures.SyncPromptSpecification> prompts,
Map<McpSchema.CompleteReference, McpServerFeatures.SyncCompletionSpecification> completions,
List<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers, String instructions) {
List<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers, String instructions,
List<McpSyncListFilter<McpSchema.Tool>> 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<McpServerFeatures.SyncToolSpecification> tools,
@ -181,8 +190,8 @@ public class McpServerFeatures {
Map<String, McpServerFeatures.SyncResourceTemplateSpecification> resourceTemplates,
Map<String, McpServerFeatures.SyncPromptSpecification> prompts,
Map<McpSchema.CompleteReference, McpServerFeatures.SyncCompletionSpecification> completions,
List<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers,
String instructions) {
List<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers, String instructions,
List<McpSyncListFilter<McpSchema.Tool>> 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();
}
}

View file

@ -80,6 +80,8 @@ public class McpStatelessAsyncServer {
private final boolean validateToolInputs;
private final McpAsyncListFilter<McpSchema.Tool> 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<String, McpStatelessRequestHandler<?>> requestHandlers = new HashMap<>();
@ -417,13 +420,32 @@ public class McpStatelessAsyncServer {
private McpStatelessRequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
return (ctx, params) -> {
List<Tool> 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<Boolean> 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<CallToolResult> toolsCallRequestHandler() {
return (ctx, params) -> {
McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params,

View file

@ -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<McpStatelessServerFeatures.AsyncToolSpecification> tools,
@ -44,7 +45,7 @@ public class McpStatelessServerFeatures {
Map<String, McpStatelessServerFeatures.AsyncResourceTemplateSpecification> resourceTemplates,
Map<String, McpStatelessServerFeatures.AsyncPromptSpecification> prompts,
Map<McpSchema.CompleteReference, McpStatelessServerFeatures.AsyncCompletionSpecification> completions,
String instructions) {
String instructions, List<McpAsyncListFilter<McpSchema.Tool>> 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<McpStatelessServerFeatures.AsyncToolSpecification> tools,
@ -62,7 +64,7 @@ public class McpStatelessServerFeatures {
Map<String, McpStatelessServerFeatures.AsyncResourceTemplateSpecification> resourceTemplates,
Map<String, McpStatelessServerFeatures.AsyncPromptSpecification> prompts,
Map<McpSchema.CompleteReference, McpStatelessServerFeatures.AsyncCompletionSpecification> completions,
String instructions) {
String instructions, List<McpAsyncListFilter<McpSchema.Tool>> 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<McpStatelessServerFeatures.SyncToolSpecification> tools,
@ -144,7 +152,7 @@ public class McpStatelessServerFeatures {
Map<String, McpStatelessServerFeatures.SyncResourceTemplateSpecification> resourceTemplates,
Map<String, McpStatelessServerFeatures.SyncPromptSpecification> prompts,
Map<McpSchema.CompleteReference, McpStatelessServerFeatures.SyncCompletionSpecification> completions,
String instructions) {
String instructions, List<McpSyncListFilter<McpSchema.Tool>> 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<McpStatelessServerFeatures.SyncToolSpecification> tools,
@ -162,7 +171,7 @@ public class McpStatelessServerFeatures {
Map<String, McpStatelessServerFeatures.SyncResourceTemplateSpecification> resourceTemplates,
Map<String, McpStatelessServerFeatures.SyncPromptSpecification> prompts,
Map<McpSchema.CompleteReference, McpStatelessServerFeatures.SyncCompletionSpecification> completions,
String instructions) {
String instructions, List<McpSyncListFilter<McpSchema.Tool>> 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();
}
}

View file

@ -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}.
* <p>
* 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<T> {
/**
* 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);
}

View file

@ -31,6 +31,24 @@ public final class Assert {
}
}
/**
* Assert that the collection contains no {@code null} elements. A {@code null} or
* empty collection passes.
* @param collection the collection to check
* @param message the exception message to use if the assertion fails
* @throws IllegalArgumentException if the collection contains a {@code null} element
*/
public static void noNullElements(@Nullable Collection<?> collection, String message) {
if (collection == null) {
return;
}
for (Object element : collection) {
if (element == null) {
throw new IllegalArgumentException(message);
}
}
}
/**
* Assert that an object is not {@code null}.
*

View file

@ -0,0 +1,192 @@
/*
* Copyright 2026 - 2026 the original author or authors.
*/
package io.modelcontextprotocol.server;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
class McpAsyncListFilterTests {
private static final Tool TOOL = Tool.builder("tool1", EMPTY_JSON_SCHEMA).build();
private static final McpTransportContext CONTEXT = McpTransportContext.create(Map.of("role", "admin"));
private static McpAsyncListFilter<Tool> constant(boolean visible) {
return (context, tool) -> Mono.just(visible);
}
@Test
void fromSyncRejectsNullFilter() {
assertThatIllegalArgumentException().isThrownBy(() -> McpAsyncListFilter.fromSync(null, false))
.withMessage("filter must not be null");
assertThatIllegalArgumentException().isThrownBy(() -> McpAsyncListFilter.fromSync(null, true))
.withMessage("filter must not be null");
}
@Test
void fromSyncPassesThroughContextAndPrimitive() {
var seenContext = new AtomicReference<McpTransportContext>();
var seenTool = new AtomicReference<Tool>();
McpSyncListFilter<Tool> filter = (context, tool) -> {
seenContext.set(context);
seenTool.set(tool);
return true;
};
StepVerifier.create(McpAsyncListFilter.fromSync(filter, true).isVisible(CONTEXT, TOOL))
.expectNext(true)
.verifyComplete();
assertThat(seenContext.get()).isSameAs(CONTEXT);
assertThat(seenTool.get()).isSameAs(TOOL);
}
@Test
void fromSyncOffloadsToBoundedElasticByDefaultSoABlockingFilterCannotStallTheTransport() {
var thread = new AtomicReference<String>();
McpSyncListFilter<Tool> filter = (context, tool) -> {
thread.set(Thread.currentThread().getName());
return false;
};
StepVerifier.create(McpAsyncListFilter.fromSync(filter, false).isVisible(CONTEXT, TOOL))
.expectNext(false)
.verifyComplete();
assertThat(thread.get()).startsWith("boundedElastic-");
}
@Test
void fromSyncRunsInlineWithImmediateExecution() {
var thread = new AtomicReference<String>();
McpSyncListFilter<Tool> filter = (context, tool) -> {
thread.set(Thread.currentThread().getName());
return true;
};
var callingThread = Thread.currentThread().getName();
StepVerifier.create(McpAsyncListFilter.fromSync(filter, true).isVisible(CONTEXT, TOOL))
.expectNext(true)
.verifyComplete();
assertThat(thread.get()).isEqualTo(callingThread);
}
@Test
void fromSyncPropagatesFilterExceptions() {
McpSyncListFilter<Tool> filter = (context, tool) -> {
throw new IllegalStateException("policy service unavailable");
};
StepVerifier.create(McpAsyncListFilter.fromSync(filter, true).isVisible(CONTEXT, TOOL))
.verifyErrorMessage("policy service unavailable");
}
@Test
void andWithoutFiltersMakesEverythingVisible() {
StepVerifier.create(McpAsyncListFilter.<Tool>and(null).isVisible(CONTEXT, TOOL))
.expectNext(true)
.verifyComplete();
StepVerifier.create(McpAsyncListFilter.<Tool>and(List.of()).isVisible(CONTEXT, TOOL))
.expectNext(true)
.verifyComplete();
}
@Test
void andWithASingleFilterReturnsThatFilterUnwrapped() {
var filter = constant(false);
assertThat(McpAsyncListFilter.and(List.of(filter))).isSameAs(filter);
}
@Test
void andRequiresEveryFilterToAccept() {
StepVerifier.create(McpAsyncListFilter.and(List.of(constant(true), constant(true))).isVisible(CONTEXT, TOOL))
.expectNext(true)
.verifyComplete();
StepVerifier.create(McpAsyncListFilter.and(List.of(constant(true), constant(false))).isVisible(CONTEXT, TOOL))
.expectNext(false)
.verifyComplete();
StepVerifier.create(McpAsyncListFilter.and(List.of(constant(false), constant(true))).isVisible(CONTEXT, TOOL))
.expectNext(false)
.verifyComplete();
}
@Test
void andShortCircuitsSoLaterFiltersAreNotConsultedAfterARejection() {
var consulted = new CopyOnWriteArrayList<String>();
McpAsyncListFilter<Tool> first = (context, tool) -> {
consulted.add("first");
return Mono.just(false);
};
McpAsyncListFilter<Tool> second = (context, tool) -> {
consulted.add("second");
return Mono.just(true);
};
StepVerifier.create(McpAsyncListFilter.and(List.of(first, second)).isVisible(CONTEXT, TOOL))
.expectNext(false)
.verifyComplete();
assertThat(consulted).containsExactly("first");
}
@Test
void andTreatsAnEmptyFilterAsHiding() {
McpAsyncListFilter<Tool> undecided = (context, tool) -> Mono.empty();
StepVerifier.create(McpAsyncListFilter.and(List.of(constant(true), undecided)).isVisible(CONTEXT, TOOL))
.expectNext(false)
.verifyComplete();
}
@Test
void andPropagatesFilterErrors() {
McpAsyncListFilter<Tool> failing = (context, tool) -> Mono.error(new IllegalStateException("policy down"));
StepVerifier.create(McpAsyncListFilter.and(List.of(constant(true), failing)).isVisible(CONTEXT, TOOL))
.verifyErrorMessage("policy down");
}
@Test
void andRejectsNullFilters() {
assertThatIllegalArgumentException()
.isThrownBy(() -> McpAsyncListFilter.and(Collections.<McpAsyncListFilter<Tool>>singletonList(null)))
.withMessage("filters must not contain null elements");
assertThatIllegalArgumentException()
.isThrownBy(() -> McpAsyncListFilter.and(Arrays.asList(constant(true), null)))
.withMessage("filters must not contain null elements");
}
@Test
void andSnapshotsTheFiltersSoLaterBuilderMutationsDoNotLeakIn() {
var filters = new java.util.ArrayList<McpAsyncListFilter<Tool>>(List.of(constant(true), constant(true)));
var composed = McpAsyncListFilter.and(filters);
filters.add(constant(false));
StepVerifier.create(composed.isVisible(CONTEXT, TOOL)).expectNext(true).verifyComplete();
}
}

View file

@ -6,6 +6,7 @@ package io.modelcontextprotocol.util;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ -27,6 +28,17 @@ class AssertTests {
assertDoesNotThrow(() -> Assert.notEmpty(List.of("test"), "collection is not empty"));
}
@Test
void testCollectionNoNullElements() {
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> Assert.noNullElements(Arrays.asList("test", null), "collection has null elements"));
assertEquals("collection has null elements", e.getMessage());
assertDoesNotThrow(() -> Assert.noNullElements(null, "collection is null"));
assertDoesNotThrow(() -> Assert.noNullElements(List.of(), "collection is empty"));
assertDoesNotThrow(() -> Assert.noNullElements(List.of("test"), "collection has no null elements"));
}
@Test
void testObjectNotNull() {
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,

View file

@ -0,0 +1,704 @@
package io.modelcontextprotocol.server;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.stream.Stream;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport;
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.Servlet;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.AfterParameterizedClassInvocation;
import org.junit.jupiter.params.BeforeParameterizedClassInvocation;
import org.junit.jupiter.params.Parameter;
import org.junit.jupiter.params.ParameterizedClass;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import reactor.core.publisher.Mono;
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Named.named;
/**
* Tests for {@link McpSyncListFilter} integration with sync and async servers. Most tests
* are implemented using the sync API, as it falls through to the async implementation.
* Some tests leverage the async API where it has specificities.
* <p>
* This is parameterized because the configuration API is duplicated across Server and
* StatelessServer implementations.
*
* @author Daniel Garnier-Moiroux
*/
@ParameterizedClass
@MethodSource("serverTypes")
class McpSyncListFilteringIntegrationTests<SYNC_TOOL_SPEC, ASYNC_TOOL_SPEC> {
@Parameter
ServerFactory<SYNC_TOOL_SPEC, ASYNC_TOOL_SPEC> serverFactory;
private static Tomcat tomcat;
private static String baseUrl;
private McpSyncClient mcpClient;
private McpSyncHttpClientRequestCustomizer requestCustomizer = (builder, method, endpoint, body, context) -> {
};
@BeforeParameterizedClassInvocation
static void createTransportAndStartTomcat(ServerFactory serverFactory) {
var port = TomcatTestUtil.findAvailablePort();
baseUrl = "http://localhost:" + port;
startTomcat(serverFactory.transport(), port);
}
@BeforeEach
void setUp() {
var clientTransport = HttpClientStreamableHttpTransport.builder(baseUrl)
.jsonMapper(McpJsonDefaults.getMapper())
.httpRequestCustomizer((builder, method, endpoint, body, context) -> requestCustomizer.customize(builder,
method, endpoint, body, context))
.openConnectionOnStartup(true)
.build();
mcpClient = McpClient.sync(clientTransport).initializationTimeout(Duration.ofMillis(500)).build();
}
@AfterEach
void tearDown() {
mcpClient.closeGracefully();
}
@AfterParameterizedClassInvocation
static void afterAll() {
stopTomcat();
}
@Test
void basicFilter() {
var visible = serverFactory.namedSyncTool("visible-tool");
var hidden = serverFactory.namedSyncTool("hidden-tool");
serverFactory.syncServer()
.tools(List.of(visible.spec(), hidden.spec()))
.addToolFilter((context, tool) -> !tool.name().equals("hidden-tool"))
.build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
}
@Test
void dynamicFilter() {
var toolSpec = serverFactory.namedSyncTool("tool");
var listCallCount = new AtomicInteger();
serverFactory.syncServer()
.tools(List.of(toolSpec.spec()))
.addToolFilter((context, tool) -> listCallCount.incrementAndGet() < 2)
.build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool());
assertThat(mcpClient.listTools().tools()).isEmpty();
}
@Test
void contextBasedFilter() {
var toolSpec = serverFactory.namedSyncTool("toolSpec");
serverFactory.syncServer()
.tools(List.of(toolSpec.spec()))
.addToolFilter(
(McpTransportContext context, McpSchema.Tool tool) -> !"true".equals(context.get("x-filter-on")))
.build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool());
requestCustomizer = (builder, method, endpoint, body, context) -> {
builder.header("x-filter-on", "true");
};
assertThat(mcpClient.listTools().tools()).isEmpty();
}
@Test
void hiddenToolIsCallable() {
var hidden = serverFactory.namedSyncTool("hidden-tool");
serverFactory.syncServer().tools(List.of(hidden.spec())).addToolFilter((context, tool) -> false).build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).isEmpty();
var response = mcpClient.callTool(McpSchema.CallToolRequest.builder("hidden-tool").arguments(Map.of()).build());
assertThat(response.content()).containsExactly(McpSchema.TextContent.builder("called hidden-tool").build());
}
@Test
void asyncFilter() {
var visible = serverFactory.namedAsyncTool("visible-tool");
var hidden = serverFactory.namedAsyncTool("hidden-tool");
serverFactory.asyncServer()
.tools(List.of(visible.spec(), hidden.spec()))
.addToolFilter(
(ctx, tool) -> Mono.delay(Duration.ofMillis(10)).thenReturn(tool.name().equals("visible-tool")))
.build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
}
@Test
void filterErrorFailsTheListingWithAnOpaqueInternalError() {
var toolSpec = serverFactory.namedAsyncTool("tool");
serverFactory.asyncServer()
.tools(List.of(toolSpec.spec()))
.addToolFilter((ctx, tool) -> Mono.error(new RuntimeException("private information")))
.build();
mcpClient.initialize();
// The listing fails, rather than silently omitting the tool: the client has no
// way to tell a filtered-down list from a partial one.
assertThatThrownBy(mcpClient::listTools).isInstanceOf(McpError.class)
.hasMessage("Internal error")
.extracting(error -> ((McpError) error).getJsonRpcError())
.satisfies(jsonRpcError -> {
assertThat(jsonRpcError.code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR);
// The filter's own diagnostics MUST NOT reach the client.
assertThat(jsonRpcError.message()).doesNotContain("private information");
assertThat(jsonRpcError.data()).isNull();
});
}
@Test
void filterMcpErrorIsPassedThroughVerbatim() {
var toolSpec = serverFactory.namedAsyncTool("tool");
serverFactory.asyncServer()
.tools(List.of(toolSpec.spec()))
.addToolFilter((ctx,
tool) -> Mono.error(McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
.message("Step-up authentication required")
.data(Map.of("scope", "tools:read"))
.build()))
.build();
mcpClient.initialize();
// An McpError is a deliberate choice by the filter author, so it is not scrubbed.
assertThatThrownBy(mcpClient::listTools).isInstanceOf(McpError.class)
.extracting(error -> ((McpError) error).getJsonRpcError())
.satisfies(jsonRpcError -> {
assertThat(jsonRpcError.code()).isEqualTo(McpSchema.ErrorCodes.INVALID_REQUEST);
assertThat(jsonRpcError.message()).isEqualTo("Step-up authentication required");
assertThat(jsonRpcError.data()).isEqualTo(Map.of("scope", "tools:read"));
});
}
@Test
void filterEmptyCompletionOmits() {
var visible = serverFactory.namedAsyncTool("visible-tool");
var hidden = serverFactory.namedAsyncTool("hidden-tool");
serverFactory.asyncServer().tools(List.of(visible.spec(), hidden.spec())).addToolFilter((ctx, tool) -> {
if (tool.name().equals("hidden-tool")) {
return Mono.empty();
}
else {
return Mono.just(true);
}
}).build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
}
@Test
void multipleFilters() {
var visible = serverFactory.namedSyncTool("visible-tool");
var hidden = serverFactory.namedSyncTool("hidden-tool");
var otherHidden = serverFactory.namedSyncTool("other-hidden-tool");
serverFactory.syncServer()
.tools(List.of(visible.spec(), hidden.spec(), otherHidden.spec()))
.addToolFilter((context, tool) -> true)
.addToolFilter((context, tool) -> !tool.name().equals("hidden-tool"))
.addToolFilter((context, tool) -> !tool.name().equals("other-hidden-tool"))
.build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
}
@Test
void multipleFiltersShortCircuitOnFirstRejection() {
var visible = serverFactory.namedSyncTool("visible-tool");
var hidden = serverFactory.namedSyncTool("hidden-tool");
serverFactory.syncServer()
.tools(List.of(visible.spec(), hidden.spec()))
.addToolFilter((context, tool) -> "visible-tool".equals(tool.name()))
.addToolFilter((context, tool) -> {
if (tool.name().equals("hidden-tool")) {
throw new RuntimeException("filter error");
}
else {
return true;
}
})
.build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
}
@Test
void toolsFilterConsumerSync() {
var toolSpec = serverFactory.namedSyncTool("tool");
serverFactory.syncServer()
.tools(List.of(toolSpec.spec()))
.addToolFilter((context, tool) -> false)
.toolFilters(filters -> {
assertThat(filters).hasSize(1);
filters.clear();
})
.build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool());
}
@Test
void toolsFilterConsumerAsync() {
var toolSpec = serverFactory.namedAsyncTool("tool");
serverFactory.asyncServer()
.tools(List.of(toolSpec.spec()))
.addToolFilter((context, tool) -> Mono.just(false))
.toolFilters(filters -> {
assertThat(filters).hasSize(1);
filters.clear();
})
.build();
mcpClient.initialize();
assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool());
}
// ----------------------------------------------------
// Test infrastructure
//
// ServerBuilderWrapper wraps stateless and stateful servers
// into a common API that the tests can use, basically
// hiding the builders behind a common type.
//
// The wrapper can produce BOTH sync and async variants
// because the filters are different in every case, one
// returning a boolean and the other a Mono<Boolean>.
// The tests use BOTH apis.
//
// The factory allows you to build a wrapper fluently,
// independent from the underlying type.
//
// ----------------------------------------------------
static Stream<Arguments> serverTypes() {
return Stream.of(Arguments.arguments(named("stateful", new StatefulServerFactory())),
Arguments.arguments(named("stateless", new StatelessServerFactory())));
}
interface ServerBuilderWrapper<TOOL_SPEC, TOOL_FILTER> {
ServerBuilderWrapper<TOOL_SPEC, TOOL_FILTER> tools(List<TOOL_SPEC> tools);
ServerBuilderWrapper<TOOL_SPEC, TOOL_FILTER> addToolFilter(TOOL_FILTER toolFilter);
ServerBuilderWrapper<TOOL_SPEC, TOOL_FILTER> toolFilters(Consumer<List<TOOL_FILTER>> toolFilterConsumer);
void build();
}
interface ToolWrapper<TOOL_SPEC> {
String name();
McpSchema.Tool tool();
TOOL_SPEC spec();
}
interface ServerFactory<SYNC_TOOL_SPEC, ASYNC_TOOL_SPEC> {
ServerBuilderWrapper<SYNC_TOOL_SPEC, McpSyncListFilter<McpSchema.Tool>> syncServer();
ServerBuilderWrapper<ASYNC_TOOL_SPEC, McpAsyncListFilter<McpSchema.Tool>> asyncServer();
ToolWrapper<SYNC_TOOL_SPEC> namedSyncTool(String name);
ToolWrapper<ASYNC_TOOL_SPEC> namedAsyncTool(String name);
Servlet transport();
}
static class StatefulServerFactory implements
ServerFactory<McpServerFeatures.SyncToolSpecification, McpServerFeatures.AsyncToolSpecification> {
private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider
.builder()
.contextExtractor(request -> {
var headers = new HashMap<String, Object>();
var names = request.getHeaderNames();
while (names.hasMoreElements()) {
String name = names.nextElement();
headers.put(name, request.getHeader(name));
}
return McpTransportContext.create(headers);
})
.build();
@Override
public ServerBuilderWrapper<McpServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> syncServer() {
return new ServerBuilderWrapper<>() {
private final McpServer.SyncSpecification<McpServer.StreamableSyncSpecification> spec = McpServer
.sync(transport)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build());
@Override
public ServerBuilderWrapper<McpServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> tools(
List<McpServerFeatures.SyncToolSpecification> tools) {
spec.tools(tools);
return this;
}
@Override
public ServerBuilderWrapper<McpServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> addToolFilter(
McpSyncListFilter<McpSchema.Tool> toolFilter) {
spec.addToolFilter(toolFilter);
return this;
}
@Override
public ServerBuilderWrapper<McpServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> toolFilters(
Consumer<List<McpSyncListFilter<McpSchema.Tool>>> toolFilterConsumer) {
spec.toolFilters(toolFilterConsumer);
return this;
}
@Override
public void build() {
spec.build();
}
};
}
@Override
public ServerBuilderWrapper<McpServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> asyncServer() {
return new ServerBuilderWrapper<>() {
private final McpServer.StreamableServerAsyncSpecification spec = (McpServer.StreamableServerAsyncSpecification) McpServer
.async(transport)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build());
@Override
public ServerBuilderWrapper<McpServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> tools(
List<McpServerFeatures.AsyncToolSpecification> tools) {
spec.tools(tools);
return this;
}
@Override
public ServerBuilderWrapper<McpServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> addToolFilter(
McpAsyncListFilter<McpSchema.Tool> toolFilter) {
spec.addToolFilter(toolFilter);
return this;
}
@Override
public ServerBuilderWrapper<McpServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> toolFilters(
Consumer<List<McpAsyncListFilter<McpSchema.Tool>>> toolFilterConsumer) {
spec.toolFilters(toolFilterConsumer);
return this;
}
@Override
public void build() {
spec.build();
}
};
}
@Override
public ToolWrapper<McpServerFeatures.SyncToolSpecification> namedSyncTool(String name) {
var tool = McpServerFeatures.SyncToolSpecification.builder()
.tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build())
.callHandler((exchange, request) -> McpSchema.CallToolResult.builder()
.addContent(McpSchema.TextContent.builder("called " + name).build())
.build())
.build();
return new ToolWrapper<>() {
@Override
public String name() {
return name;
}
@Override
public McpSchema.Tool tool() {
return tool.tool();
}
@Override
public McpServerFeatures.SyncToolSpecification spec() {
return tool;
}
};
}
@Override
public ToolWrapper<McpServerFeatures.AsyncToolSpecification> namedAsyncTool(String name) {
var tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build())
.callHandler((exchange,
request) -> Mono.just(McpSchema.CallToolResult.builder()
.addContent(McpSchema.TextContent.builder("called " + name).build())
.build()))
.build();
return new ToolWrapper<>() {
@Override
public String name() {
return name;
}
@Override
public McpSchema.Tool tool() {
return tool.tool();
}
@Override
public McpServerFeatures.AsyncToolSpecification spec() {
return tool;
}
};
}
@Override
public Servlet transport() {
return transport;
}
}
static class StatelessServerFactory implements
ServerFactory<McpStatelessServerFeatures.SyncToolSpecification, McpStatelessServerFeatures.AsyncToolSpecification> {
private final HttpServletStatelessServerTransport transport = HttpServletStatelessServerTransport.builder()
.contextExtractor(request -> {
var headers = new HashMap<String, Object>();
var names = request.getHeaderNames();
while (names.hasMoreElements()) {
String name = names.nextElement();
headers.put(name, request.getHeader(name));
}
return McpTransportContext.create(headers);
})
.build();
@Override
public ServerBuilderWrapper<McpStatelessServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> syncServer() {
return new ServerBuilderWrapper<>() {
private final McpServer.StatelessSyncSpecification spec = McpServer.sync(transport)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build());
@Override
public ServerBuilderWrapper<McpStatelessServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> tools(
List<McpStatelessServerFeatures.SyncToolSpecification> tools) {
spec.tools(tools);
return this;
}
@Override
public ServerBuilderWrapper<McpStatelessServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> addToolFilter(
McpSyncListFilter<McpSchema.Tool> toolFilter) {
spec.addToolFilter(toolFilter);
return this;
}
@Override
public ServerBuilderWrapper<McpStatelessServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> toolFilters(
Consumer<List<McpSyncListFilter<McpSchema.Tool>>> toolFilterConsumer) {
spec.toolFilters(toolFilterConsumer);
return this;
}
@Override
public void build() {
spec.build();
}
};
}
@Override
public ServerBuilderWrapper<McpStatelessServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> asyncServer() {
return new ServerBuilderWrapper<>() {
private final McpServer.StatelessAsyncSpecification spec = McpServer.async(transport)
.serverInfo("test-server", "1.0.0")
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build());
@Override
public ServerBuilderWrapper<McpStatelessServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> tools(
List<McpStatelessServerFeatures.AsyncToolSpecification> tools) {
spec.tools(tools);
return this;
}
@Override
public ServerBuilderWrapper<McpStatelessServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> addToolFilter(
McpAsyncListFilter<McpSchema.Tool> toolFilter) {
spec.addToolFilter(toolFilter);
return this;
}
@Override
public ServerBuilderWrapper<McpStatelessServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> toolFilters(
Consumer<List<McpAsyncListFilter<McpSchema.Tool>>> toolFilterConsumer) {
spec.toolFilters(toolFilterConsumer);
return this;
}
@Override
public void build() {
spec.build();
}
};
}
@Override
public ToolWrapper<McpStatelessServerFeatures.SyncToolSpecification> namedSyncTool(String name) {
var tool = McpStatelessServerFeatures.SyncToolSpecification.builder()
.tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build())
.callHandler((exchange, request) -> McpSchema.CallToolResult.builder()
.addContent(McpSchema.TextContent.builder("called " + name).build())
.build())
.build();
return new ToolWrapper<>() {
@Override
public String name() {
return name;
}
@Override
public McpSchema.Tool tool() {
return tool.tool();
}
@Override
public McpStatelessServerFeatures.SyncToolSpecification spec() {
return tool;
}
};
}
@Override
public ToolWrapper<McpStatelessServerFeatures.AsyncToolSpecification> namedAsyncTool(String name) {
var tool = McpStatelessServerFeatures.AsyncToolSpecification.builder()
.tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build())
.callHandler((exchange,
request) -> Mono.just(McpSchema.CallToolResult.builder()
.addContent(McpSchema.TextContent.builder("called " + name).build())
.build()))
.build();
return new ToolWrapper<>() {
@Override
public String name() {
return name;
}
@Override
public McpSchema.Tool tool() {
return tool.tool();
}
@Override
public McpStatelessServerFeatures.AsyncToolSpecification spec() {
return tool;
}
};
}
@Override
public Servlet transport() {
return transport;
}
}
// ----------------------------------------------------
// Tomcat management
// ----------------------------------------------------
private static void startTomcat(jakarta.servlet.Servlet servlet, int port) {
tomcat = TomcatTestUtil.createTomcatServer("", port, servlet);
try {
tomcat.start();
assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
}
catch (Exception e) {
throw new RuntimeException("Failed to start Tomcat", e);
}
}
private static void stopTomcat() {
if (tomcat != null) {
try {
tomcat.stop();
tomcat.destroy();
}
catch (LifecycleException e) {
throw new RuntimeException("Failed to stop Tomcat", e);
}
}
}
}