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
8 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}.
*