Skip to content

Commit 6d8dbaf

Browse files
committed
Add filtering for MCP tool list
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent fb029c8 commit 6d8dbaf

10 files changed

Lines changed: 1263 additions & 23 deletions

File tree

docs/server.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,88 @@ 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 enfore 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.
493+
- Filters accumulate as a boolean **AND**: a tool is listed only when every registered filter accepts it, so a
494+
later `addToolFilter(...)` can never widen access. Evaluation follows registration order and
495+
short-circuits on the first filter that hides a tool.
496+
- `toolFilters(Consumer<List<...>>)` hands you the list of filters registered so far, so you can
497+
inspect, reorder or clear them before building — useful when filters come from several places:
498+
499+
```java
500+
McpServer.sync(transportProvider)
501+
.addToolFilter(tenantFilter)
502+
.toolFilters(filters -> filters.add(0, cheapDenyAllForAnonymousFilter))
503+
.build();
504+
```
505+
506+
- Tools are tested one at a time, so a filter that performs I/O per tool costs one round trip per
507+
tool. Resolve per-request state **once** in your `contextExtractor` and read it in the filter:
508+
509+
```java
510+
// one authorization lookup, shared by every tool tested in this request
511+
.contextExtractor(request -> McpTransportContext.create(
512+
Map.of("perms", introspect(request.getHeader("Authorization")).cache())))
513+
514+
.addToolFilter((context, tool) ->
515+
((Set<String>) context.get("perms")).contains(tool.name())
516+
)
517+
```
518+
519+
- `notifications/tools/list_changed` is **not** filtered. It is a server-initiated broadcast with
520+
no request in flight, so there is no context to evaluate. A client may be told something changed
521+
when its own visible set did not; it gets the correct view on its next `tools/list`. Consider disabling
522+
this notification entirely when using tool filters.
523+
- With STDIO there is no per-request metadata, so the filter receives `McpTransportContext.EMPTY` and has nothing to key
524+
on.
525+
444526
### Resource Specification
445527

446528
Specification of a resource with its handler function.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
*/
65+
static <T> McpAsyncListFilter<T> and(List<McpAsyncListFilter<T>> filters) {
66+
if (filters == null || filters.isEmpty()) {
67+
return (transportContext, primitive) -> Mono.just(Boolean.TRUE);
68+
}
69+
if (filters.size() == 1) {
70+
return filters.get(0);
71+
}
72+
List<McpAsyncListFilter<T>> snapshot = List.copyOf(filters);
73+
return (transportContext, primitive) -> Flux.fromIterable(snapshot)
74+
.concatMap(filter -> filter.isVisible(transportContext, primitive).defaultIfEmpty(Boolean.FALSE))
75+
.all(Boolean.TRUE::equals);
76+
}
77+
78+
}

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

Lines changed: 11 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,9 +541,13 @@ 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+
.collectList()
550+
.map(tools -> McpSchema.ListToolsResult.builder(tools).build());
543551
};
544552
}
545553

0 commit comments

Comments
 (0)