Skip to content

feat(binding-mcp): per-route allow-set filtering for tools/prompts/resources (#1833)#1841

Merged
jfallows merged 11 commits into
developfrom
claude/dazzling-fermat-lr1wV
Jun 6, 2026
Merged

feat(binding-mcp): per-route allow-set filtering for tools/prompts/resources (#1833)#1841
jfallows merged 11 commits into
developfrom
claude/dazzling-fermat-lr1wV

Conversation

@jfallows
Copy link
Copy Markdown
Contributor

@jfallows jfallows commented Jun 5, 2026

Summary

Adds optional per-route allow-sets to the MCP proxy routes[].when condition, letting an operator restrict which tools/prompts/resources a federated upstream exposes. Two complementary behaviors:

  1. Authoritative invocation enforcement — a tools/call / prompts/get / resources/read to a name outside the route's allow-set fails to resolve and is rejected, even when the client supplies the name directly.
  2. Advisory list filteringtools/list / prompts/list / resources/list federation results only advertise permitted primitives (live path and cache path).

Config shape (per when item, proxy kind only):

routes:
  - exit: app1
    when:
      - toolkit: github
        capability: [tools, resources]
        tools: ["create_*", "get_*"]
        resources: ["repo://*"]

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

  • Config/matcher: new tools/prompts/resources glob fields on McpConditionConfig; the previously-nested condition matcher is extracted to a top-level McpConditionMatcher (mirrors MqttKafkaConditionMatcher) with the allow-set predicate. Names are matched via the JSON parser, so item field order doesn't matter.
  • Schema: adds the three array fields plus cross-field validation rejecting a name field whose type is excluded from capability; fields are forbidden on non-proxy kinds.
  • Invocation enforcement: falls out of route resolution — match() now gates the stripped name, so McpProxyItemFactory resolves no route and the stream is rejected.
  • List filtering (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 same McpListServer.

Tests (test-first)

  • Unit: McpConditionMatcherTest (glob admit/deny, absent/empty/non-empty, serves/admits/filters), McpConditionConfigAdapterTest (round-trip incl. empty allow-set)
  • Schema: valid filter config + cross-field rejection
  • ITs: McpProxyIT invocation rejection, McpProxyIT live-path list filtering, McpProxyCacheIT cache-path list filtering at hydration
  • Full binding-mcp + binding-mcp.spec suite passes (129 unit + 428 IT), 0 checkstyle violations, all JaCoCo coverage met

Notes

  • Docs for the new route fields live in the separate docs repo and are not included here.

Resolves #1833.

https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB


Generated by Claude Code

claude added 9 commits June 5, 2026 04:43
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
this.originId = server.lifecycle.originId;
this.routedId = routedId;
this.prefix = prefix;
this.route = route;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can we just pull admits as a functional interface?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a9ad556McpListClient 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

claude added 2 commits June 5, 2026 23:21
…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
Copy link
Copy Markdown
Contributor Author

@jfallows jfallows left a comment

Choose a reason for hiding this comment

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

LGTM

@jfallows jfallows merged commit f92c102 into develop Jun 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

binding-mcp: per-route primitive-name allow-set filtering for mcp proxy

2 participants