@@ -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
0 commit comments