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
12 changed files with 1386 additions and 23 deletions
|
|
@ -441,6 +441,102 @@ 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: a client cannot tell a filtered-down listing from a
|
||||
partial one, and MCP has no way to signal "this listing was incomplete, retry".
|
||||
- A filter that errors is logged server-side and reported to the client as an opaque
|
||||
`-32603 Internal error` with no `data`. If you want the client to see a specific error, throw an
|
||||
`McpError`, those are passed through.
|
||||
- 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. 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();
|
||||
```
|
||||
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,704 @@
|
|||
package io.modelcontextprotocol.server;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.json.McpJsonDefaults;
|
||||
import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport;
|
||||
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import jakarta.servlet.Servlet;
|
||||
import org.apache.catalina.LifecycleException;
|
||||
import org.apache.catalina.LifecycleState;
|
||||
import org.apache.catalina.startup.Tomcat;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.AfterParameterizedClassInvocation;
|
||||
import org.junit.jupiter.params.BeforeParameterizedClassInvocation;
|
||||
import org.junit.jupiter.params.Parameter;
|
||||
import org.junit.jupiter.params.ParameterizedClass;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.jupiter.api.Named.named;
|
||||
|
||||
/**
|
||||
* Tests for {@link McpSyncListFilter} integration with sync and async servers. Most tests
|
||||
* are implemented using the sync API, as it falls through to the async implementation.
|
||||
* Some tests leverage the async API where it has specificities.
|
||||
* <p>
|
||||
* This is parameterized because the configuration API is duplicated across Server and
|
||||
* StatelessServer implementations.
|
||||
*
|
||||
* @author Daniel Garnier-Moiroux
|
||||
*/
|
||||
@ParameterizedClass
|
||||
@MethodSource("serverTypes")
|
||||
class McpSyncListFilteringIntegrationTests<SYNC_TOOL_SPEC, ASYNC_TOOL_SPEC> {
|
||||
|
||||
@Parameter
|
||||
ServerFactory<SYNC_TOOL_SPEC, ASYNC_TOOL_SPEC> serverFactory;
|
||||
|
||||
private static Tomcat tomcat;
|
||||
|
||||
private static String baseUrl;
|
||||
|
||||
private McpSyncClient mcpClient;
|
||||
|
||||
private McpSyncHttpClientRequestCustomizer requestCustomizer = (builder, method, endpoint, body, context) -> {
|
||||
|
||||
};
|
||||
|
||||
@BeforeParameterizedClassInvocation
|
||||
static void createTransportAndStartTomcat(ServerFactory serverFactory) {
|
||||
var port = TomcatTestUtil.findAvailablePort();
|
||||
baseUrl = "http://localhost:" + port;
|
||||
startTomcat(serverFactory.transport(), port);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
var clientTransport = HttpClientStreamableHttpTransport.builder(baseUrl)
|
||||
.jsonMapper(McpJsonDefaults.getMapper())
|
||||
.httpRequestCustomizer((builder, method, endpoint, body, context) -> requestCustomizer.customize(builder,
|
||||
method, endpoint, body, context))
|
||||
.openConnectionOnStartup(true)
|
||||
.build();
|
||||
|
||||
mcpClient = McpClient.sync(clientTransport).initializationTimeout(Duration.ofMillis(500)).build();
|
||||
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
mcpClient.closeGracefully();
|
||||
}
|
||||
|
||||
@AfterParameterizedClassInvocation
|
||||
static void afterAll() {
|
||||
stopTomcat();
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicFilter() {
|
||||
var visible = serverFactory.namedSyncTool("visible-tool");
|
||||
var hidden = serverFactory.namedSyncTool("hidden-tool");
|
||||
|
||||
serverFactory.syncServer()
|
||||
.tools(List.of(visible.spec(), hidden.spec()))
|
||||
.addToolFilter((context, tool) -> !tool.name().equals("hidden-tool"))
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
|
||||
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dynamicFilter() {
|
||||
var toolSpec = serverFactory.namedSyncTool("tool");
|
||||
var listCallCount = new AtomicInteger();
|
||||
|
||||
serverFactory.syncServer()
|
||||
.tools(List.of(toolSpec.spec()))
|
||||
.addToolFilter((context, tool) -> listCallCount.incrementAndGet() < 2)
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool());
|
||||
assertThat(mcpClient.listTools().tools()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void contextBasedFilter() {
|
||||
var toolSpec = serverFactory.namedSyncTool("toolSpec");
|
||||
|
||||
serverFactory.syncServer()
|
||||
.tools(List.of(toolSpec.spec()))
|
||||
.addToolFilter(
|
||||
(McpTransportContext context, McpSchema.Tool tool) -> !"true".equals(context.get("x-filter-on")))
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool());
|
||||
|
||||
requestCustomizer = (builder, method, endpoint, body, context) -> {
|
||||
builder.header("x-filter-on", "true");
|
||||
};
|
||||
assertThat(mcpClient.listTools().tools()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hiddenToolIsCallable() {
|
||||
var hidden = serverFactory.namedSyncTool("hidden-tool");
|
||||
|
||||
serverFactory.syncServer().tools(List.of(hidden.spec())).addToolFilter((context, tool) -> false).build();
|
||||
|
||||
mcpClient.initialize();
|
||||
assertThat(mcpClient.listTools().tools()).isEmpty();
|
||||
var response = mcpClient.callTool(McpSchema.CallToolRequest.builder("hidden-tool").arguments(Map.of()).build());
|
||||
assertThat(response.content()).containsExactly(McpSchema.TextContent.builder("called hidden-tool").build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFilter() {
|
||||
var visible = serverFactory.namedAsyncTool("visible-tool");
|
||||
var hidden = serverFactory.namedAsyncTool("hidden-tool");
|
||||
|
||||
serverFactory.asyncServer()
|
||||
.tools(List.of(visible.spec(), hidden.spec()))
|
||||
.addToolFilter(
|
||||
(ctx, tool) -> Mono.delay(Duration.ofMillis(10)).thenReturn(tool.name().equals("visible-tool")))
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterErrorFailsTheListingWithAnOpaqueInternalError() {
|
||||
var toolSpec = serverFactory.namedAsyncTool("tool");
|
||||
|
||||
serverFactory.asyncServer()
|
||||
.tools(List.of(toolSpec.spec()))
|
||||
.addToolFilter((ctx, tool) -> Mono.error(new RuntimeException("private information")))
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
|
||||
// The listing fails, rather than silently omitting the tool: the client has no
|
||||
// way to tell a filtered-down list from a partial one.
|
||||
assertThatThrownBy(mcpClient::listTools).isInstanceOf(McpError.class)
|
||||
.hasMessage("Internal error")
|
||||
.extracting(error -> ((McpError) error).getJsonRpcError())
|
||||
.satisfies(jsonRpcError -> {
|
||||
assertThat(jsonRpcError.code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR);
|
||||
// The filter's own diagnostics MUST NOT reach the client.
|
||||
assertThat(jsonRpcError.message()).doesNotContain("private information");
|
||||
assertThat(jsonRpcError.data()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterMcpErrorIsPassedThroughVerbatim() {
|
||||
var toolSpec = serverFactory.namedAsyncTool("tool");
|
||||
|
||||
serverFactory.asyncServer()
|
||||
.tools(List.of(toolSpec.spec()))
|
||||
.addToolFilter((ctx,
|
||||
tool) -> Mono.error(McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
|
||||
.message("Step-up authentication required")
|
||||
.data(Map.of("scope", "tools:read"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
|
||||
// An McpError is a deliberate choice by the filter author, so it is not scrubbed.
|
||||
assertThatThrownBy(mcpClient::listTools).isInstanceOf(McpError.class)
|
||||
.extracting(error -> ((McpError) error).getJsonRpcError())
|
||||
.satisfies(jsonRpcError -> {
|
||||
assertThat(jsonRpcError.code()).isEqualTo(McpSchema.ErrorCodes.INVALID_REQUEST);
|
||||
assertThat(jsonRpcError.message()).isEqualTo("Step-up authentication required");
|
||||
assertThat(jsonRpcError.data()).isEqualTo(Map.of("scope", "tools:read"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterEmptyCompletionOmits() {
|
||||
var visible = serverFactory.namedAsyncTool("visible-tool");
|
||||
var hidden = serverFactory.namedAsyncTool("hidden-tool");
|
||||
|
||||
serverFactory.asyncServer().tools(List.of(visible.spec(), hidden.spec())).addToolFilter((ctx, tool) -> {
|
||||
if (tool.name().equals("hidden-tool")) {
|
||||
return Mono.empty();
|
||||
}
|
||||
else {
|
||||
return Mono.just(true);
|
||||
}
|
||||
}).build();
|
||||
|
||||
mcpClient.initialize();
|
||||
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleFilters() {
|
||||
var visible = serverFactory.namedSyncTool("visible-tool");
|
||||
var hidden = serverFactory.namedSyncTool("hidden-tool");
|
||||
var otherHidden = serverFactory.namedSyncTool("other-hidden-tool");
|
||||
|
||||
serverFactory.syncServer()
|
||||
.tools(List.of(visible.spec(), hidden.spec(), otherHidden.spec()))
|
||||
.addToolFilter((context, tool) -> true)
|
||||
.addToolFilter((context, tool) -> !tool.name().equals("hidden-tool"))
|
||||
.addToolFilter((context, tool) -> !tool.name().equals("other-hidden-tool"))
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
|
||||
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleFiltersShortCircuitOnFirstRejection() {
|
||||
var visible = serverFactory.namedSyncTool("visible-tool");
|
||||
var hidden = serverFactory.namedSyncTool("hidden-tool");
|
||||
|
||||
serverFactory.syncServer()
|
||||
.tools(List.of(visible.spec(), hidden.spec()))
|
||||
.addToolFilter((context, tool) -> "visible-tool".equals(tool.name()))
|
||||
.addToolFilter((context, tool) -> {
|
||||
if (tool.name().equals("hidden-tool")) {
|
||||
throw new RuntimeException("filter error");
|
||||
}
|
||||
else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
|
||||
assertThat(mcpClient.listTools().tools()).containsExactly(visible.tool());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsFilterConsumerSync() {
|
||||
var toolSpec = serverFactory.namedSyncTool("tool");
|
||||
|
||||
serverFactory.syncServer()
|
||||
.tools(List.of(toolSpec.spec()))
|
||||
.addToolFilter((context, tool) -> false)
|
||||
.toolFilters(filters -> {
|
||||
assertThat(filters).hasSize(1);
|
||||
filters.clear();
|
||||
})
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
|
||||
assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolsFilterConsumerAsync() {
|
||||
var toolSpec = serverFactory.namedAsyncTool("tool");
|
||||
|
||||
serverFactory.asyncServer()
|
||||
.tools(List.of(toolSpec.spec()))
|
||||
.addToolFilter((context, tool) -> Mono.just(false))
|
||||
.toolFilters(filters -> {
|
||||
assertThat(filters).hasSize(1);
|
||||
filters.clear();
|
||||
})
|
||||
.build();
|
||||
|
||||
mcpClient.initialize();
|
||||
|
||||
assertThat(mcpClient.listTools().tools()).containsExactly(toolSpec.tool());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Test infrastructure
|
||||
//
|
||||
// ServerBuilderWrapper wraps stateless and stateful servers
|
||||
// into a common API that the tests can use, basically
|
||||
// hiding the builders behind a common type.
|
||||
//
|
||||
// The wrapper can produce BOTH sync and async variants
|
||||
// because the filters are different in every case, one
|
||||
// returning a boolean and the other a Mono<Boolean>.
|
||||
// The tests use BOTH apis.
|
||||
//
|
||||
// The factory allows you to build a wrapper fluently,
|
||||
// independent from the underlying type.
|
||||
//
|
||||
// ----------------------------------------------------
|
||||
|
||||
static Stream<Arguments> serverTypes() {
|
||||
return Stream.of(Arguments.arguments(named("stateful", new StatefulServerFactory())),
|
||||
Arguments.arguments(named("stateless", new StatelessServerFactory())));
|
||||
}
|
||||
|
||||
interface ServerBuilderWrapper<TOOL_SPEC, TOOL_FILTER> {
|
||||
|
||||
ServerBuilderWrapper<TOOL_SPEC, TOOL_FILTER> tools(List<TOOL_SPEC> tools);
|
||||
|
||||
ServerBuilderWrapper<TOOL_SPEC, TOOL_FILTER> addToolFilter(TOOL_FILTER toolFilter);
|
||||
|
||||
ServerBuilderWrapper<TOOL_SPEC, TOOL_FILTER> toolFilters(Consumer<List<TOOL_FILTER>> toolFilterConsumer);
|
||||
|
||||
void build();
|
||||
|
||||
}
|
||||
|
||||
interface ToolWrapper<TOOL_SPEC> {
|
||||
|
||||
String name();
|
||||
|
||||
McpSchema.Tool tool();
|
||||
|
||||
TOOL_SPEC spec();
|
||||
|
||||
}
|
||||
|
||||
interface ServerFactory<SYNC_TOOL_SPEC, ASYNC_TOOL_SPEC> {
|
||||
|
||||
ServerBuilderWrapper<SYNC_TOOL_SPEC, McpSyncListFilter<McpSchema.Tool>> syncServer();
|
||||
|
||||
ServerBuilderWrapper<ASYNC_TOOL_SPEC, McpAsyncListFilter<McpSchema.Tool>> asyncServer();
|
||||
|
||||
ToolWrapper<SYNC_TOOL_SPEC> namedSyncTool(String name);
|
||||
|
||||
ToolWrapper<ASYNC_TOOL_SPEC> namedAsyncTool(String name);
|
||||
|
||||
Servlet transport();
|
||||
|
||||
}
|
||||
|
||||
static class StatefulServerFactory implements
|
||||
ServerFactory<McpServerFeatures.SyncToolSpecification, McpServerFeatures.AsyncToolSpecification> {
|
||||
|
||||
private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider
|
||||
.builder()
|
||||
.contextExtractor(request -> {
|
||||
var headers = new HashMap<String, Object>();
|
||||
var names = request.getHeaderNames();
|
||||
while (names.hasMoreElements()) {
|
||||
String name = names.nextElement();
|
||||
headers.put(name, request.getHeader(name));
|
||||
}
|
||||
return McpTransportContext.create(headers);
|
||||
})
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> syncServer() {
|
||||
return new ServerBuilderWrapper<>() {
|
||||
private final McpServer.SyncSpecification<McpServer.StreamableSyncSpecification> spec = McpServer
|
||||
.sync(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build());
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> tools(
|
||||
List<McpServerFeatures.SyncToolSpecification> tools) {
|
||||
spec.tools(tools);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> addToolFilter(
|
||||
McpSyncListFilter<McpSchema.Tool> toolFilter) {
|
||||
spec.addToolFilter(toolFilter);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> toolFilters(
|
||||
Consumer<List<McpSyncListFilter<McpSchema.Tool>>> toolFilterConsumer) {
|
||||
spec.toolFilters(toolFilterConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void build() {
|
||||
spec.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> asyncServer() {
|
||||
return new ServerBuilderWrapper<>() {
|
||||
private final McpServer.StreamableServerAsyncSpecification spec = (McpServer.StreamableServerAsyncSpecification) McpServer
|
||||
.async(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build());
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> tools(
|
||||
List<McpServerFeatures.AsyncToolSpecification> tools) {
|
||||
spec.tools(tools);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> addToolFilter(
|
||||
McpAsyncListFilter<McpSchema.Tool> toolFilter) {
|
||||
spec.addToolFilter(toolFilter);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> toolFilters(
|
||||
Consumer<List<McpAsyncListFilter<McpSchema.Tool>>> toolFilterConsumer) {
|
||||
spec.toolFilters(toolFilterConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void build() {
|
||||
spec.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolWrapper<McpServerFeatures.SyncToolSpecification> namedSyncTool(String name) {
|
||||
var tool = McpServerFeatures.SyncToolSpecification.builder()
|
||||
.tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build())
|
||||
.callHandler((exchange, request) -> McpSchema.CallToolResult.builder()
|
||||
.addContent(McpSchema.TextContent.builder("called " + name).build())
|
||||
.build())
|
||||
.build();
|
||||
return new ToolWrapper<>() {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpSchema.Tool tool() {
|
||||
return tool.tool();
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpServerFeatures.SyncToolSpecification spec() {
|
||||
return tool;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolWrapper<McpServerFeatures.AsyncToolSpecification> namedAsyncTool(String name) {
|
||||
var tool = McpServerFeatures.AsyncToolSpecification.builder()
|
||||
.tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build())
|
||||
.callHandler((exchange,
|
||||
request) -> Mono.just(McpSchema.CallToolResult.builder()
|
||||
.addContent(McpSchema.TextContent.builder("called " + name).build())
|
||||
.build()))
|
||||
.build();
|
||||
return new ToolWrapper<>() {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpSchema.Tool tool() {
|
||||
return tool.tool();
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpServerFeatures.AsyncToolSpecification spec() {
|
||||
return tool;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Servlet transport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class StatelessServerFactory implements
|
||||
ServerFactory<McpStatelessServerFeatures.SyncToolSpecification, McpStatelessServerFeatures.AsyncToolSpecification> {
|
||||
|
||||
private final HttpServletStatelessServerTransport transport = HttpServletStatelessServerTransport.builder()
|
||||
.contextExtractor(request -> {
|
||||
var headers = new HashMap<String, Object>();
|
||||
var names = request.getHeaderNames();
|
||||
while (names.hasMoreElements()) {
|
||||
String name = names.nextElement();
|
||||
headers.put(name, request.getHeader(name));
|
||||
}
|
||||
return McpTransportContext.create(headers);
|
||||
})
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpStatelessServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> syncServer() {
|
||||
return new ServerBuilderWrapper<>() {
|
||||
private final McpServer.StatelessSyncSpecification spec = McpServer.sync(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build());
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpStatelessServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> tools(
|
||||
List<McpStatelessServerFeatures.SyncToolSpecification> tools) {
|
||||
spec.tools(tools);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpStatelessServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> addToolFilter(
|
||||
McpSyncListFilter<McpSchema.Tool> toolFilter) {
|
||||
spec.addToolFilter(toolFilter);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpStatelessServerFeatures.SyncToolSpecification, McpSyncListFilter<McpSchema.Tool>> toolFilters(
|
||||
Consumer<List<McpSyncListFilter<McpSchema.Tool>>> toolFilterConsumer) {
|
||||
spec.toolFilters(toolFilterConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void build() {
|
||||
spec.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpStatelessServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> asyncServer() {
|
||||
return new ServerBuilderWrapper<>() {
|
||||
private final McpServer.StatelessAsyncSpecification spec = McpServer.async(transport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build());
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpStatelessServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> tools(
|
||||
List<McpStatelessServerFeatures.AsyncToolSpecification> tools) {
|
||||
spec.tools(tools);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpStatelessServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> addToolFilter(
|
||||
McpAsyncListFilter<McpSchema.Tool> toolFilter) {
|
||||
spec.addToolFilter(toolFilter);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerBuilderWrapper<McpStatelessServerFeatures.AsyncToolSpecification, McpAsyncListFilter<McpSchema.Tool>> toolFilters(
|
||||
Consumer<List<McpAsyncListFilter<McpSchema.Tool>>> toolFilterConsumer) {
|
||||
spec.toolFilters(toolFilterConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void build() {
|
||||
spec.build();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolWrapper<McpStatelessServerFeatures.SyncToolSpecification> namedSyncTool(String name) {
|
||||
var tool = McpStatelessServerFeatures.SyncToolSpecification.builder()
|
||||
.tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build())
|
||||
.callHandler((exchange, request) -> McpSchema.CallToolResult.builder()
|
||||
.addContent(McpSchema.TextContent.builder("called " + name).build())
|
||||
.build())
|
||||
.build();
|
||||
return new ToolWrapper<>() {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpSchema.Tool tool() {
|
||||
return tool.tool();
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpStatelessServerFeatures.SyncToolSpecification spec() {
|
||||
return tool;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolWrapper<McpStatelessServerFeatures.AsyncToolSpecification> namedAsyncTool(String name) {
|
||||
var tool = McpStatelessServerFeatures.AsyncToolSpecification.builder()
|
||||
.tool(McpSchema.Tool.builder(name, EMPTY_JSON_SCHEMA).description(name + " description").build())
|
||||
.callHandler((exchange,
|
||||
request) -> Mono.just(McpSchema.CallToolResult.builder()
|
||||
.addContent(McpSchema.TextContent.builder("called " + name).build())
|
||||
.build()))
|
||||
.build();
|
||||
return new ToolWrapper<>() {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpSchema.Tool tool() {
|
||||
return tool.tool();
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpStatelessServerFeatures.AsyncToolSpecification spec() {
|
||||
return tool;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Servlet transport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Tomcat management
|
||||
// ----------------------------------------------------
|
||||
|
||||
private static void startTomcat(jakarta.servlet.Servlet servlet, int port) {
|
||||
tomcat = TomcatTestUtil.createTomcatServer("", port, servlet);
|
||||
try {
|
||||
tomcat.start();
|
||||
assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Failed to start Tomcat", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void stopTomcat() {
|
||||
if (tomcat != null) {
|
||||
try {
|
||||
tomcat.stop();
|
||||
tomcat.destroy();
|
||||
}
|
||||
catch (LifecycleException e) {
|
||||
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue