Skip to content

Commit e8d7718

Browse files
committed
Address PR feedback
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 836277b commit e8d7718

6 files changed

Lines changed: 98 additions & 22 deletions

File tree

docs/server.md

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,11 @@ The same `addToolFilter(...)` method is available on the stateless builders.
489489
legitimately see different results for two successive requests carrying different credentials.
490490
- Registration order is preserved; only omissions happen.
491491
- Returning `Mono.empty()` from an async filter omits the tool. An error fails the whole listing
492-
request rather than silently hiding tools.
492+
request rather than silently hiding tools: a client cannot tell a filtered-down listing from a
493+
partial one, and MCP has no way to signal "this listing was incomplete, retry".
494+
- A filter that errors is logged server-side and reported to the client as an opaque
495+
`-32603 Internal error` with no `data`. If you want the client to see a specific error, throw an
496+
`McpError`, those are passed through.
493497
- Filters accumulate as a boolean **AND**: a tool is listed only when every registered filter accepts it, so a
494498
later `addToolFilter(...)` can never widen access. Evaluation follows registration order and
495499
short-circuits on the first filter that hides a tool.
@@ -504,16 +508,26 @@ The same `addToolFilter(...)` method is available on the stateless builders.
504508
```
505509

506510
- 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:
511+
tool. Sync filters also run on a shared scheduler thread — not the request thread — unless
512+
`immediateExecution(true)` is set, so thread-bound request state (Spring Security's
513+
`SecurityContextHolder`, MDC, custom `ThreadLocal` holders) is **not visible** inside the filter.
514+
For both reasons, resolve per-request state **once** in the transport's `contextExtractor`,
515+
which does run on the request thread, and read only the extracted context in the filter:
508516

509517
```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")))))
518+
// transport builder: one authorization lookup, on the request thread,
519+
// shared by every tool tested in this request
520+
var transportProvider = HttpServletStreamableServerTransportProvider.builder()
521+
.contextExtractor(request -> McpTransportContext.create(
522+
Map.of("perms", introspect(request.getHeader("Authorization")))))
523+
// ...
524+
.build();
513525

514-
.addToolFilter((context, tool) ->
515-
((Set<String>) context.get("perms")).contains(tool.name())
516-
)
526+
// server builder: the filter reads only the extracted context
527+
McpServer.sync(transportProvider)
528+
.addToolFilter((context, tool) ->
529+
((Set<String>) context.get("perms")).contains(tool.name()))
530+
.build();
517531
```
518532

519533
- `notifications/tools/list_changed` is **not** filtered. It is a server-initiated broadcast with

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@ static <T> McpAsyncListFilter<T> fromSync(McpSyncListFilter<T> filter, boolean i
6565
* contain {@code null} elements.
6666
*/
6767
static <T> McpAsyncListFilter<T> and(List<McpAsyncListFilter<T>> filters) {
68-
Assert.noNullElements(filters, "filters must not contain null elements");
6968
if (filters == null || filters.isEmpty()) {
7069
return (transportContext, primitive) -> Mono.just(Boolean.TRUE);
7170
}
71+
Assert.noNullElements(filters, "filters must not contain null elements");
7272
if (filters.size() == 1) {
7373
return filters.get(0);
7474
}

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,12 +545,28 @@ private McpRequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
545545
// view, otherwise page offsets leak the number of hidden tools.
546546
return Flux.fromIterable(this.tools)
547547
.map(McpServerFeatures.AsyncToolSpecification::tool)
548-
.filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool))
548+
.filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool)
549+
.onErrorResume(error -> opaqueListFilterError(tool, error)))
549550
.collectList()
550551
.map(tools -> McpSchema.ListToolsResult.builder(tools).build());
551552
};
552553
}
553554

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

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1671,10 +1671,7 @@ public StatelessAsyncSpecification validateToolInputs(boolean validate) {
16711671
* accepts it, so a later registration can never widen access.
16721672
* <p>
16731673
* A hidden tool is omitted from listings only. It remains callable by name, so
1674-
* enforce permissions in the tool's call handler. Tools are NOT hidden from
1675-
* {@code notifications/tools/list_changed}, as it is a per-client context rather
1676-
* than per-request. Consider disabling list changed notifications entirely when
1677-
* using filters.
1674+
* enforce permissions in the tool's call handler.
16781675
* <p>
16791676
* @param toolFilter the filter to add, must not be null
16801677
* @return This builder instance for method chaining
@@ -2211,10 +2208,7 @@ public StatelessSyncSpecification validateToolInputs(boolean validate) {
22112208
* accepts it, so a later registration can never widen access.
22122209
* <p>
22132210
* A hidden tool is omitted from listings only. It remains callable by name, so
2214-
* enforce permissions in the tool's call handler. Tools are NOT hidden from
2215-
* {@code notifications/tools/list_changed}, as it is a per-client context rather
2216-
* than per-request. Consider disabling list changed notifications entirely when
2217-
* using filters.
2211+
* enforce permissions in the tool's call handler.
22182212
* <p>
22192213
* The filter is offloaded to a bounded elastic scheduler unless
22202214
* {@link #immediateExecution(boolean)} is set.

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,12 +424,28 @@ private McpStatelessRequestHandler<McpSchema.ListToolsResult> toolsListRequestHa
424424
// view, otherwise page offsets leak the number of hidden tools.
425425
return Flux.fromIterable(this.tools)
426426
.map(McpStatelessServerFeatures.AsyncToolSpecification::tool)
427-
.filterWhen(tool -> this.toolFilter.isVisible(ctx, tool))
427+
.filterWhen(tool -> this.toolFilter.isVisible(ctx, tool)
428+
.onErrorResume(error -> opaqueListFilterError(tool, error)))
428429
.collectList()
429430
.map(tools -> McpSchema.ListToolsResult.builder(tools).build());
430431
};
431432
}
432433

434+
/**
435+
* Report a list filter failure to the client as an opaque {@code -32603} error, so
436+
* that filter internals such as identity provider hostnames or the reason a principal
437+
* was rejected never leave the server. The actual cause is logged instead. An
438+
* {@link McpError} is deliberate on the filter's part and passes through untouched.
439+
*/
440+
private static Mono<Boolean> opaqueListFilterError(Tool tool, Throwable error) {
441+
if (error instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
442+
logger.debug("Tool list filter failed for tool '{}' with an explicit MCP error", tool.name(), error);
443+
return Mono.error(mcpError);
444+
}
445+
logger.error("Tool list filter failed for tool '{}', failing the tools/list request", tool.name(), error);
446+
return Mono.error(McpError.builder(ErrorCodes.INTERNAL_ERROR).message("Internal error").build());
447+
}
448+
433449
private McpStatelessRequestHandler<CallToolResult> toolsCallRequestHandler() {
434450
return (ctx, params) -> {
435451
McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params,

mcp-test/src/test/java/io/modelcontextprotocol/server/McpSyncListFilteringIntegrationTests.java

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,16 +174,52 @@ void asyncFilter() {
174174
}
175175

176176
@Test
177-
void filterErrorPropagates() {
177+
void filterErrorFailsTheListingWithAnOpaqueInternalError() {
178178
var toolSpec = serverFactory.namedAsyncTool("tool");
179179

180180
serverFactory.asyncServer()
181181
.tools(List.of(toolSpec.spec()))
182-
.addToolFilter((ctx, tool) -> Mono.error(new RuntimeException("filter error")))
182+
.addToolFilter((ctx, tool) -> Mono.error(new RuntimeException("private information")))
183183
.build();
184184

185185
mcpClient.initialize();
186-
assertThatThrownBy(mcpClient::listTools).isInstanceOf(McpError.class).hasMessage("filter error");
186+
187+
// The listing fails, rather than silently omitting the tool: the client has no
188+
// way to tell a filtered-down list from a partial one.
189+
assertThatThrownBy(mcpClient::listTools).isInstanceOf(McpError.class)
190+
.hasMessage("Internal error")
191+
.extracting(error -> ((McpError) error).getJsonRpcError())
192+
.satisfies(jsonRpcError -> {
193+
assertThat(jsonRpcError.code()).isEqualTo(McpSchema.ErrorCodes.INTERNAL_ERROR);
194+
// The filter's own diagnostics MUST NOT reach the client.
195+
assertThat(jsonRpcError.message()).doesNotContain("private information");
196+
assertThat(jsonRpcError.data()).isNull();
197+
});
198+
}
199+
200+
@Test
201+
void filterMcpErrorIsPassedThroughVerbatim() {
202+
var toolSpec = serverFactory.namedAsyncTool("tool");
203+
204+
serverFactory.asyncServer()
205+
.tools(List.of(toolSpec.spec()))
206+
.addToolFilter((ctx,
207+
tool) -> Mono.error(McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
208+
.message("Step-up authentication required")
209+
.data(Map.of("scope", "tools:read"))
210+
.build()))
211+
.build();
212+
213+
mcpClient.initialize();
214+
215+
// An McpError is a deliberate choice by the filter author, so it is not scrubbed.
216+
assertThatThrownBy(mcpClient::listTools).isInstanceOf(McpError.class)
217+
.extracting(error -> ((McpError) error).getJsonRpcError())
218+
.satisfies(jsonRpcError -> {
219+
assertThat(jsonRpcError.code()).isEqualTo(McpSchema.ErrorCodes.INVALID_REQUEST);
220+
assertThat(jsonRpcError.message()).isEqualTo("Step-up authentication required");
221+
assertThat(jsonRpcError.data()).isEqualTo(Map.of("scope", "tools:read"));
222+
});
187223
}
188224

189225
@Test

0 commit comments

Comments
 (0)