Skip to content

Commit 2293cf7

Browse files
authored
Add filtering for MCP tool list (#1108)
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent a7bfddc commit 2293cf7

12 files changed

Lines changed: 1386 additions & 23 deletions

File tree

docs/server.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,102 @@ var syncToolSpecification = SyncToolSpecification.builder()
441441

442442
`ImageContent.builder(data, mimeType)` and `AudioContent.builder(data, mimeType)` both take base64-encoded binary data. `EmbeddedResource.builder(resourceContents)` wraps either a `TextResourceContents` (for text data) or a `BlobResourceContents` (for base64-encoded binary data) — see [Reading Binary Resources](#reading-binary-resources) for the `BlobResourceContents` shape.
443443

444+
### Filtering the Tool Listing per Request
445+
446+
By default every registered tool is advertised to every caller. Over an HTTP transport you can
447+
vary the `tools/list` response per request — to hide tools the caller is not authorized to see,
448+
or to trim a large catalog down to a relevant subset — by registering one or more tool filters.
449+
450+
The filter receives the `McpTransportContext` extracted from the current request, so it can key
451+
on HTTP headers, a token, a resolved principal, or anything else your
452+
`contextExtractor` puts there.
453+
454+
=== "Sync"
455+
456+
```java
457+
McpServer.sync(transportProvider)
458+
.tools(publicTool, adminTool)
459+
.addToolFilter((transportContext, tool) ->
460+
!tool.name().startsWith("admin-") || isAdmin(transportContext))
461+
.build();
462+
```
463+
464+
=== "Async"
465+
466+
```java
467+
McpServer.async(transportProvider)
468+
.tools(publicTool, adminTool)
469+
.addToolFilter((transportContext, tool) -> {
470+
if (!tool.name().startsWith("admin-")) {
471+
return Mono.just(true);
472+
}
473+
return isAdmin(transportContext); // Mono<Boolean>
474+
})
475+
.build();
476+
```
477+
478+
The same `addToolFilter(...)` method is available on the stateless builders.
479+
480+
!!! warning "Hiding a tool does not make it unreachable"
481+
482+
The filter controls **advertisement only**. A hidden tool called by name still executes:
483+
you MUST enforce permissions in the tool's call handler. Use the filter to control what a
484+
caller is told about, not what they are allowed to do.
485+
486+
**Evaluation semantics**
487+
488+
- The filter is consulted on **every** listing request and never cached, so the same session may
489+
legitimately see different results for two successive requests carrying different credentials.
490+
- Registration order is preserved; only omissions happen.
491+
- Returning `Mono.empty()` from an async filter omits the tool. An error fails the whole listing
492+
request rather than silently hiding tools: a client cannot tell a filtered-down listing from a
493+
partial one, and MCP has no way to signal "this listing was incomplete, retry".
494+
- A filter that errors is logged server-side and reported to the client as an opaque
495+
`-32603 Internal error` with no `data`. If you want the client to see a specific error, throw an
496+
`McpError`, those are passed through.
497+
- Filters accumulate as a boolean **AND**: a tool is listed only when every registered filter accepts it, so a
498+
later `addToolFilter(...)` can never widen access. Evaluation follows registration order and
499+
short-circuits on the first filter that hides a tool.
500+
- `toolFilters(Consumer<List<...>>)` hands you the list of filters registered so far, so you can
501+
inspect, reorder or clear them before building — useful when filters come from several places:
502+
503+
```java
504+
McpServer.sync(transportProvider)
505+
.addToolFilter(tenantFilter)
506+
.toolFilters(filters -> filters.add(0, cheapDenyAllForAnonymousFilter))
507+
.build();
508+
```
509+
510+
- Tools are tested one at a time, so a filter that performs I/O per tool costs one round trip per
511+
tool. Sync filters also run on a shared scheduler thread — not the request thread — unless
512+
`immediateExecution(true)` is set, so thread-bound request state (Spring Security's
513+
`SecurityContextHolder`, MDC, custom `ThreadLocal` holders) is **not visible** inside the filter.
514+
For both reasons, resolve per-request state **once** in the transport's `contextExtractor`,
515+
which does run on the request thread, and read only the extracted context in the filter:
516+
517+
```java
518+
// transport builder: one authorization lookup, on the request thread,
519+
// shared by every tool tested in this request
520+
var transportProvider = HttpServletStreamableServerTransportProvider.builder()
521+
.contextExtractor(request -> McpTransportContext.create(
522+
Map.of("perms", introspect(request.getHeader("Authorization")))))
523+
// ...
524+
.build();
525+
526+
// server builder: the filter reads only the extracted context
527+
McpServer.sync(transportProvider)
528+
.addToolFilter((context, tool) ->
529+
((Set<String>) context.get("perms")).contains(tool.name()))
530+
.build();
531+
```
532+
533+
- `notifications/tools/list_changed` is **not** filtered. It is a server-initiated broadcast with
534+
no request in flight, so there is no context to evaluate. A client may be told something changed
535+
when its own visible set did not; it gets the correct view on its next `tools/list`. Consider disabling
536+
this notification entirely when using tool filters.
537+
- With STDIO there is no per-request metadata, so the filter receives `McpTransportContext.EMPTY` and has nothing to key
538+
on.
539+
444540
### Resource Specification
445541

446542
Specification of a resource with its handler function.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.server;
6+
7+
import java.util.List;
8+
9+
import io.modelcontextprotocol.common.McpTransportContext;
10+
import io.modelcontextprotocol.spec.McpSchema.Tool;
11+
import io.modelcontextprotocol.util.Assert;
12+
import reactor.core.publisher.Flux;
13+
import reactor.core.publisher.Mono;
14+
import reactor.core.scheduler.Schedulers;
15+
16+
/**
17+
* Decide per request whether a primitive is advertised in the corresponding listing, such
18+
* as {@code tools/list}.
19+
* <p>
20+
* A primitive hidden by this filter is omitted from listings ONLY. It remains reachable
21+
* through its own endpoint: a hidden tool called by name still executes. Permissions MUST
22+
* be enforced in the primitive's handler.
23+
*
24+
* @author Daniel Garnier-Moiroux
25+
* @see McpSyncListFilter
26+
* @see McpTransportContextExtractor
27+
*/
28+
@FunctionalInterface
29+
public interface McpAsyncListFilter<T> {
30+
31+
/**
32+
* Whether the given primitive is visible to the caller of the current request.
33+
* @param transportContext transport context containing, for example, HTTP headers or
34+
* a resolved principal. Should never be {@code null}, but may
35+
* {@link McpTransportContext#EMPTY} for transports that carry no per-request
36+
* metadata, such as STDIO.
37+
* @param primitive the primitive that is a candidate for inclusion in the listing,
38+
* such as {@link Tool}.
39+
* @return a publisher emitting {@code true} to include the primitive in the listing,
40+
* {@code false} to omit it. Completing empty omits the primitive; erroring fails the
41+
* listing request.
42+
*/
43+
Mono<Boolean> isVisible(McpTransportContext transportContext, T primitive);
44+
45+
/**
46+
* Convert a potentially blocking, synchronous filter into an asynchronous one,
47+
* offloading it to prevent accidental blocking of a non-blocking transport.
48+
* @param filter the synchronous filter. MUST NOT be null.
49+
* @param immediateExecution When true, do not offload work asynchronously. Do NOT set
50+
* to true when the filter performs blocking I/O.
51+
*/
52+
static <T> McpAsyncListFilter<T> fromSync(McpSyncListFilter<T> filter, boolean immediateExecution) {
53+
Assert.notNull(filter, "filter must not be null");
54+
return (transportContext, primitive) -> {
55+
var visible = Mono.fromCallable(() -> filter.isVisible(transportContext, primitive));
56+
return immediateExecution ? visible : visible.subscribeOn(Schedulers.boundedElastic());
57+
};
58+
}
59+
60+
/**
61+
* Combine multiple filters in a single AND-filter. An empty or {@code null} list
62+
* makes everything visible, keeping listing on a single code path when nothing is
63+
* configured.
64+
* @param filters the filters to combine. May be {@code null} or empty, but MUST NOT
65+
* contain {@code null} elements.
66+
*/
67+
static <T> McpAsyncListFilter<T> and(List<McpAsyncListFilter<T>> filters) {
68+
if (filters == null || filters.isEmpty()) {
69+
return (transportContext, primitive) -> Mono.just(Boolean.TRUE);
70+
}
71+
Assert.noNullElements(filters, "filters must not contain null elements");
72+
if (filters.size() == 1) {
73+
return filters.get(0);
74+
}
75+
List<McpAsyncListFilter<T>> snapshot = List.copyOf(filters);
76+
return (transportContext, primitive) -> Flux.fromIterable(snapshot)
77+
.concatMap(filter -> filter.isVisible(transportContext, primitive).defaultIfEmpty(Boolean.FALSE))
78+
.all(Boolean.TRUE::equals);
79+
}
80+
81+
}

mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ public class McpAsyncServer {
118118

119119
private final ConcurrentHashMap<String, Set<String>> resourceSubscriptions = new ConcurrentHashMap<>();
120120

121+
private final McpAsyncListFilter<McpSchema.Tool> toolFilter;
122+
121123
private List<String> protocolVersions;
122124

123125
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory();
@@ -146,6 +148,7 @@ public class McpAsyncServer {
146148
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
147149
this.jsonSchemaValidator = jsonSchemaValidator;
148150
this.validateToolInputs = validateToolInputs;
151+
this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
149152

150153
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
151154
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
@@ -177,6 +180,7 @@ public class McpAsyncServer {
177180
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
178181
this.jsonSchemaValidator = jsonSchemaValidator;
179182
this.validateToolInputs = validateToolInputs;
183+
this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
180184

181185
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
182186
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
@@ -537,12 +541,32 @@ public Mono<Void> notifyToolsListChanged() {
537541

538542
private McpRequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
539543
return (exchange, params) -> {
540-
List<Tool> tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
541-
542-
return Mono.just(McpSchema.ListToolsResult.builder(tools).build());
544+
// TODO: Implement pagination. Cursors must be computed over the filtered
545+
// view, otherwise page offsets leak the number of hidden tools.
546+
return Flux.fromIterable(this.tools)
547+
.map(McpServerFeatures.AsyncToolSpecification::tool)
548+
.filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool)
549+
.onErrorResume(error -> opaqueListFilterError(tool, error)))
550+
.collectList()
551+
.map(tools -> McpSchema.ListToolsResult.builder(tools).build());
543552
};
544553
}
545554

555+
/**
556+
* Report a list filter failure to the client as an opaque {@code -32603} error, so
557+
* that filter internals such as identity provider hostnames or the reason a principal
558+
* was rejected never leave the server. The actual cause is logged instead. An
559+
* {@link McpError} is deliberate on the filter's part and passes through untouched.
560+
*/
561+
private static Mono<Boolean> opaqueListFilterError(Tool tool, Throwable error) {
562+
if (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
563+
logger.debug("Tool list filter failed for tool '{}' with an explicit MCP error", tool.name(), error);
564+
return Mono.error(mcpError);
565+
}
566+
logger.error("Tool list filter failed for tool '{}', failing the tools/list request", tool.name(), error);
567+
return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR).message("Internal error").build());
568+
}
569+
546570
private McpRequestHandler<CallToolResult> toolsCallRequestHandler() {
547571
return (exchange, params) -> {
548572
McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params,

0 commit comments

Comments
 (0)