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
10 changed files with 1386 additions and 23 deletions

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,