feat(binding-mcp): per-route allow-set filtering for tools/prompts/resources (#1833)#1841
Merged
Conversation
Add optional tools/prompts/resources glob allow-sets to the mcp proxy route `when` condition. Semantics: absent = no constraint; present = allow-set (match >=1 glob); empty = matches nothing. Capability advertisement stays driven solely by `capability` (an empty allow-set does not suppress it). Invocation enforcement (authoritative) falls out of route resolution: McpConditionMatcher.match() now gates the stripped name against the per-capability allow-set, so tools/call, prompts/get and resources/read to a name outside the set fail to resolve and are rejected. Extracts the previously-nested ConditionMatcher to a top-level McpConditionMatcher (mirrors MqttKafkaConditionMatcher) for direct unit testing. Schema adds the three array fields plus cross-field validation rejecting a name field whose type is excluded from `capability`. Advisory list-federation filtering is deferred to a follow-up. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
#1833) Add a McpProxyIT scenario proving end-to-end invocation enforcement: a tools/call to a name outside the route allow-set (bluesky__delete_account vs tools: [get_*]) is rejected by the proxy with a stream reset (`connect aborted`), while the lifecycle handshake completes normally. The route resolves to null in McpProxyItemFactory so no upstream stream is opened. Like other route-reject scenarios (e.g. http.unknown.path), the upstream server script is lifecycle-only and the rejection is zilla-enforced, so there is no complementary peer ApplicationIT pair. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
…1833) Filter tools/list, prompts/list and resources/list federation results by the per-route allow-set, so listings only advertise permitted primitives. The federated emitter streams each item chunk-by-chunk, so to drop an item by name without breaking the backpressure/retention machine, filtering defers item emission: in filtering mode an item is scanned (without emitting) until its name/uri is decoded, then either emitted from the start (begin + prefix-injected body, reusing the existing backpressure path) or skipped. Non-filtering routes keep the exact original streaming path, so unfiltered lists are byte-identical and unaffected. Per item, the name is taken from the JSON parser, so field order does not matter. Items too large to inspect before the slot boundary pass through unfiltered (advisory filter; invocation enforcement remains the security boundary). The cache path inherits filtering because hydration runs through the same McpListServer, so cache entries are populated already filtered. McpRouteConfig gains admits(kind|capability, name) and filters(kind), threaded to the list client via McpRoutePrefix. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
Add a McpProxyCacheIT scenario proving the cache path filters: a client tools/list triggers hydration, the upstream returns a full 3-tool list, zilla filters it to the 2 tools matching the route allow-set (get_*), prefixes them (bluesky__), stores the filtered result, and serves it back. Confirms cache entries are populated already-filtered via the shared hydration McpListServer path. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
Re-kick after an unrelated flaky failure in specs/filesystem-http.spec (ApplicationIT.shouldWatch timed out after 5s); binding-mcp modules were skipped, not failed. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
Re-kick after unrelated flaky failures: Build (25) hit a timing flake in McpServerIT.shouldCallToolElicitCompleted (SSE/elicitation, passes locally) and Analyze (java) failed in the CodeQL build/extraction step (regular build compiled all modules). Neither relates to this PR's changes. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
…eted Pre-existing CI timing flake in the elicitation/SSE flow, unrelated to the allow-set changes in this PR (passes locally; fails intermittently in CI with ComparisonFailure/TestTimedOut). Ignored to unblock the PR build while the real fix is prepared separately. REVERT before merge once the fix lands on develop. Marker: TODO(#1841). https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
…fix Reverts the temporary @ignore now that the elicitation/SSE timing fix is merged to develop (#1842) and merged into this branch. The merge had slid the @ignore onto the newly-added shouldCallToolWithUpstreamResumableFlush; removing it restores both tests as active. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
jfallows
commented
Jun 5, 2026
| this.originId = server.lifecycle.originId; | ||
| this.routedId = routedId; | ||
| this.prefix = prefix; | ||
| this.route = route; |
Contributor
Author
There was a problem hiding this comment.
Can we just pull admits as a functional interface?
Contributor
Author
There was a problem hiding this comment.
Done in a9ad556 — McpListClient now takes a Predicate<String> admits instead of the McpRouteConfig. The predicate is built once per client in onNextClient (null when the route has no allow-set for the capability), and the deferred-scan path engages when it's non-null. Full MCP suite green.
Generated by Claude Code
…erface Address review feedback: McpListClient now takes a Predicate<String> admit function instead of holding the whole McpRouteConfig. The predicate is built once per client in onNextClient (null when the route has no allow-set for the capability), and the deferred-scan path engages when it is non-null. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds optional per-route allow-sets to the MCP proxy
routes[].whencondition, letting an operator restrict which tools/prompts/resources a federated upstream exposes. Two complementary behaviors:tools/call/prompts/get/resources/readto a name outside the route's allow-set fails to resolve and is rejected, even when the client supplies the name directly.tools/list/prompts/list/resources/listfederation results only advertise permitted primitives (live path and cache path).Config shape (per
whenitem, proxy kind only):Semantics: field absent = no constraint; present = allow-set (match ≥1 glob); empty = matches nothing. Capability advertisement stays driven solely by
capability(an empty allow-set does not suppress it).Implementation
tools/prompts/resourcesglob fields onMcpConditionConfig; the previously-nested condition matcher is extracted to a top-levelMcpConditionMatcher(mirrorsMqttKafkaConditionMatcher) with the allow-set predicate. Names are matched via the JSON parser, so item field order doesn't matter.capability; fields are forbidden on non-proxy kinds.match()now gates the stripped name, soMcpProxyItemFactoryresolves no route and the stream is rejected.McpProxyListFactory, the streaming hot path): in filtering mode an item is scanned (without emitting) until its name/uri is decoded, then emitted-from-start (begin + prefix-injected body, reusing the existing backpressure path) or skipped. Non-filtering routes keep the exact original streaming path, so unfiltered lists are byte-identical. Items too large to inspect before the slot boundary pass through unfiltered (advisory; invocation enforcement is the security boundary). The cache path inherits filtering because hydration runs through the sameMcpListServer.Tests (test-first)
McpConditionMatcherTest(glob admit/deny, absent/empty/non-empty,serves/admits/filters),McpConditionConfigAdapterTest(round-trip incl. empty allow-set)McpProxyITinvocation rejection,McpProxyITlive-path list filtering,McpProxyCacheITcache-path list filtering at hydrationbinding-mcp+binding-mcp.specsuite passes (129 unit + 428 IT), 0 checkstyle violations, all JaCoCo coverage metNotes
Resolves #1833.
https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB
Generated by Claude Code