Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,88 @@ var syncToolSpecification = SyncToolSpecification.builder()

`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.

### Filtering the Tool Listing per Request

By default every registered tool is advertised to every caller. Over an HTTP transport you can
vary the `tools/list` response per request — to hide tools the caller is not authorized to see,
or to trim a large catalog down to a relevant subset — by registering one or more tool filters.

The filter receives the `McpTransportContext` extracted from the current request, so it can key
on HTTP headers, a token, a resolved principal, or anything else your
`contextExtractor` puts there.

=== "Sync"

```java
McpServer.sync(transportProvider)
.tools(publicTool, adminTool)
.addToolFilter((transportContext, tool) ->
!tool.name().startsWith("admin-") || isAdmin(transportContext))
.build();
```

=== "Async"

```java
McpServer.async(transportProvider)
.tools(publicTool, adminTool)
.addToolFilter((transportContext, tool) -> {
if (!tool.name().startsWith("admin-")) {
return Mono.just(true);
}
return isAdmin(transportContext); // Mono<Boolean>
})
.build();
```

The same `addToolFilter(...)` method is available on the stateless builders.

!!! warning "Hiding a tool does not make it unreachable"

The filter controls **advertisement only**. A hidden tool called by name still executes:
you MUST enforce permissions in the tool's call handler. Use the filter to control what a
caller is told about, not what they are allowed to do.

**Evaluation semantics**

- The filter is consulted on **every** listing request and never cached, so the same session may
legitimately see different results for two successive requests carrying different credentials.
- Registration order is preserved; only omissions happen.
- Returning `Mono.empty()` from an async filter omits the tool. An error fails the whole listing
request rather than silently hiding tools.
- Filters accumulate as a boolean **AND**: a tool is listed only when every registered filter accepts it, so a
later `addToolFilter(...)` can never widen access. Evaluation follows registration order and
short-circuits on the first filter that hides a tool.
- `toolFilters(Consumer<List<...>>)` hands you the list of filters registered so far, so you can
inspect, reorder or clear them before building — useful when filters come from several places:

```java
McpServer.sync(transportProvider)
.addToolFilter(tenantFilter)
.toolFilters(filters -> filters.add(0, cheapDenyAllForAnonymousFilter))
.build();
```

- Tools are tested one at a time, so a filter that performs I/O per tool costs one round trip per
tool. Resolve per-request state **once** in your `contextExtractor` and read it in the filter:

```java
// one authorization lookup, shared by every tool tested in this request
.contextExtractor(request -> McpTransportContext.create(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this is from the transport while the addToolFilter is from the server builder. Snippet gives the wrong impression that they belong to a common builder.
AI suggested a re-write like:

Sync filters also run on a shared scheduler thread — not the request thread — unless
immediateExecution(true) is set, so thread-bound request state (Spring Security's
SecurityContextHolder, MDC, custom ThreadLocal holders) is not visible inside the filter.
For both reasons, resolve per-request state once in the transport's contextExtractor,
which does run on the request thread, and read only the extracted context in the filter:

```java
// transport builder: one authorization lookup, on the request thread,
// shared by every tool tested in this request
var transportProvider = HttpServletStreamableServerTransportProvider.builder()
    .contextExtractor(request -> McpTransportContext.create(
            Map.of("perms", introspect(request.getHeader("Authorization")))))
    // ...
    .build();

// server builder: the filter reads only the extracted context
McpServer.sync(transportProvider)
    .addToolFilter((context, tool) ->
            ((Set<String>) context.get("perms")).contains(tool.name()))
    .build();
```

Map.of("perms", introspect(request.getHeader("Authorization")))))

.addToolFilter((context, tool) ->
((Set<String>) context.get("perms")).contains(tool.name())
)
```

- `notifications/tools/list_changed` is **not** filtered. It is a server-initiated broadcast with
no request in flight, so there is no context to evaluate. A client may be told something changed
when its own visible set did not; it gets the correct view on its next `tools/list`. Consider disabling
this notification entirely when using tool filters.
- With STDIO there is no per-request metadata, so the filter receives `McpTransportContext.EMPTY` and has nothing to key
on.

### Resource Specification

Specification of a resource with its handler function.
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Assert.noNullElements(filters, "filters must not contain null elements");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this assert can go after the if (filters == null || filters.isEmpty()) check.

if (filters == null || filters.isEmpty()) {
return (transportContext, primitive) -> Mono.just(Boolean.TRUE);
}
if (filters.size() == 1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With 1 filter, Mono.empty() stays empty; with 2+, empty is coerced to false: (e.g..defaultIfEmpty(Boolean.FALSE)). Not sure what downstream implications this might have. It will be cleaner to either remove the if (filters.size() == 1) optimization or use the same defaultIfEmpty strategy

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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -537,9 +541,13 @@ public Mono<Void> notifyToolsListChanged() {

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filterWhen doesn't catch any Mono.error or exceptions thrown in the filters and this error is propagated through the McpServerSession as McpError back to the MCP client. Not sure if it is safe to let the clients see those type of filter errors?

.collectList()
.map(tools -> McpSchema.ListToolsResult.builder(tools).build());
};
}

Expand Down
Loading
Loading