Add filtering for MCP tool list (#1108)
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
parent
a7bfddc361
commit
2293cf70b5
10 changed files with 1386 additions and 23 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
@ -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}.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue