feat(mcp): scope custom tool queries to a tenant from a verified token claim - #745
Open
vishal-bala wants to merge 8 commits into
Open
vishal-bala wants to merge 8 commits into
vishal-bala wants to merge 8 commits into
Conversation
Phase 2 of custom MCP tools scopes every query a profile runs to the tenant
carried in the caller's verified token. This is the first of two slices: the
three functions that carry the security property, with no configuration model
and no server wiring, so all of it is unit-testable.
`resolve_injected_claim` reads one claim and accepts only a single non-empty
unpadded string. It sits directly below `authorization_values`, which reads
`access_token.claims` under the opposite rules -- that reader space-splits a
string and coerces list members, because an absent scope only ever denies,
whereas a widened value here grants. Keeping the two adjacent, each commented
with why it differs from its neighbour, is what stops them being unified.
The type check is the load-bearing one. `_formatted_tag_value` escapes each
element before joining them with `|`, so a list claim renders a genuine
cross-tenant union that no character scan of the output can tell from a
legitimate one -- the `|` is structure rather than content. The scalar `|`
check stays as a backstop for the character class changing again.
`build_injected_filter` builds one tag equality per entry, ANDs them, and
refuses the whole request when any single claim is unusable. Its match-all
check runs on each clause alone, before combining: an intersection elides a
`*` operand, so a check on the combined expression would pass while the tenant
clause had silently vanished.
`validate_inject_against_schema` fails startup when an injected field is
absent, is not a tag, or is NOINDEX. A NOINDEX field is the worst of the three
because it returns nothing and so looks like correct scoping.
`_is_match_all_filter` moves from `redisvl/index/index.py` to
`redisvl/query/filter.py` as public `is_match_all_filter`, with its one call
site updated. It exists rather than a bare `str(expr) != "*"` because an
un-initialized `FilterExpression` raises on render; that case now has a test.
Injected fields are tag fields only. Text equality is an exact-phrase match
and text tokenizes on punctuation, so `@t:("acme-corp")` also matches
`acme-corp-eu` -- widening, and `no_stem` does not fix it.
Every guard was mutation-checked by reverting it alone. One did not fail any
test: the match-all check is unreachable behind the claim reader's own empty
check, so it is now pinned by a test that substitutes that reader, which is
exactly the future it insures against.
…ion/01-security-core
…n claim A profile can now take its tenant from the caller's verified token instead of trusting the model to pass a filter. `lock.inject` names an index tag field and the claim that fills it; the value is AND-combined into every query the profile runs, never appears in the advertised input schema, and is left out of the description's field hints. The injected expression pre-folds into the locked side of the merge rather than travelling beside it. `merge_locked_filter` returns the caller's filter untouched when nothing is locked, so only a non-None locked side forces the caller's filter through the escape backstop. A profile with injection and no static lock would otherwise AND the tenant clause and skip that check. Startup refuses an injecting profile when authentication is not enabled, on any transport, and when authentication is configured but the server runs over stdio, which FastMCP never authenticates. `run_async` now records the transport, resolving an omitted one the way FastMCP does, since it is the only point in the process that knows it. Both checks run before any binding connects, because they depend only on the loaded config. The CLI needs no matching check: it reaches the server-side one, and so does any embedder. Config load rejects an empty `inject` list, a field injected twice, a field also constrained by `lock.filter`, `required: false`, and a missing or non-`claim` `from`. Startup rejects an injected field that is absent, not a tag, or NOINDEX, and warns when it is not CASESENSITIVE, since `Acme` and `acme` would otherwise be one tenant. A changed tool surface after an in-process restart stays a warning, except when injection is configured on either side of the change, where it is now fatal. Adding injection to a profile registered without it would otherwise leave that tool serving every tenant while the config says it does not. `_validate_custom_tools` on the server becomes `_validate_custom_tools_against_schema`, ending its name collision with `MCPConfig._validate_custom_tools`. The DSL clause walker moves from the profile module into config, where load-time validation now needs it. The docs gain the feature, its startup checks, and its threat model, and lose two statements that RedisVL never maps claims to query filters.
… `|` A six-perspective review of the claim-injection stack, measured on Redis 8.4, found that the claim reader let through values that match a different tenant. Inside tag braces the query parser reads every control character and the backtick as a term separator -- the controls even when escaped -- so `\x01acme` matched the tenant `acme` and `acme\tcorp` matched `acme corp`. A sweep of U+0000-U+024F, the general punctuation block, and the CJK and fullwidth ranges found exactly 33 such code points: U+0001-U+001F, U+007F, and U+0060. The claim reader now refuses any control character or backtick. Escaping the backtick in `Tag` generally is left to its own change. The `|` refusal goes. `Tag` escapes `|` inside a single value and Redis matches the escaped form literally, so the check protected nothing and refused real identifiers: an Auth0 `sub` is shaped `auth0|64f1c2`. A list claim, which is the shape that genuinely unions, is still refused on type. The request-time field re-check in `build_injected_filter` is removed, with its `schema` parameter. It checked the schema captured at registration, so it could never fire, and its comment claimed the opposite. Startup already re-validates the freshly inspected schema on every start. The clause-containment check gains the test it lacked: reverting it failed nothing, so the earlier claim that every guard had been mutation-checked was wrong. The case-folding warning moves into `validate_inject_against_schema`, beside the hard schema checks, so any caller gets it rather than only profiles. A refusal is no longer logged twice: FastMCP already logs the raised error.
…p-claim-injection/02-config-enforcement-docs
Injection isolates a tool, but tenant data lives in an index, and every tool passes the same read-scope gate. The six-perspective review found that an injecting profile's index stayed reachable without the tenant clause: an enabled `search-records` read every tenant, an enabled `upsert-records` let one tenant retag another's document as its own (hash storage merges a partial record into the existing key), and a second profile without `lock.inject` on the same index was simply unscoped. Only the docs warned. Startup now refuses that surface, naming each route it found. Writes are no route to a read-only index, server-wide or per binding, since those refuse per call; an unscoped profile on a different index is no route either. The check depends only on config, so it runs beside the token check, before any binding connects. This is configuration that voids the guarantee it declares, which is why it fails rather than warns. Merged in from the security core: the claim reader refuses control characters and the backtick and now accepts `|`, and `build_injected_filter` no longer takes a schema. The profile wrapper follows suit, gates injection on `is not None` so an empty list would reach the builder's refusal rather than skip it, and drops its case-folding warning, which moved beside the other schema checks. Tests: a wire-level test drives a real FastMCP server through an in-memory client and shows a call naming the injected field is rejected before the wrapper runs, replacing an assertion that could never fail. The integration suite keeps what depends on real Redis -- including a live check that an Auth0-shaped claim `auth0|64f1c2` matches only its own document -- and drops the cases the unit matrices already own. Docs: the claim that the model is never told the field exists was false, since results and `list-indexes` show it, and is corrected. The threat model gains the write path, separator splitting, index-time trimming, JSON arrays and Unicode-wide case folding; the stdio refusal is qualified to the entrypoints that record a transport; the worked example disables `upsert-records`, without which it would no longer start.
…ion/02-config-enforcement-docs # Conflicts: # redisvl/mcp/auth.py # tests/unit/test_mcp/test_auth_claim_injection.py
vishal-bala
marked this pull request as ready for review
October 2, 2026 12:49
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 0b8848b. Configure here.
…scope The startup check that refuses unscoped routes to a tenant-scoped index treated any `lock.inject` as the tenant scope. A second profile on the same index injecting a different field passed, and so did one injecting the same field from a different claim. Either reads across the tenants the first profile separates: scoped by `region` alone, a caller sees every organisation's documents in its region. The scope is a property of the index, so every custom tool on it must now inject the same set of (field, claim) entries, compared as a set so entry order does not matter. A tool injecting nothing is the empty set and is caught by the same comparison, which replaces the separate "no `lock.inject`" branch. The error lists each tool on the index with the scope it injects, so the mismatch is visible in one line.
This branch has not been deployed
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.

Motivation
When tenants share one index, separated by a field such as
org_id, exposingsearch-recordsmakes the tenant boundary depend on the model remembering to pass a filter. One forgotten filter is a cross-tenant read, and no prompt closes that gap. This PR lets a custom tool profile take the tenant from the caller's verified token instead: every query the profile runs is AND-scoped to the value of a named claim, and the model can neither see nor set it. It builds on the security core merged in #738 and wires it into configuration, the profile wrapper and server startup.The guarantee is narrow and stated exactly: a client presenting a validly signed token cannot make the model widen or escape the tenant scope carried in that token. The trust boundary is the identity provider, not the MCP client.
Changes
Configuration
A profile declares injection under
lock:fieldandclaimare independent names, because the index and the identity provider each choose their own.frommust be stated and must beclaim, andrequiredaccepts onlytrue, because optional injection is unscoped injection. Config load rejects an empty list, a field injected twice, and a field thatlock.filteralso constrains, since a static lock and an injected value on one field are two answers to the same question.Enforcement on every call
The wrapper resolves the claim per request and folds the result into the profile's locked filter instead of passing it alongside. That matters because
merge_locked_filterreturns the caller's filter untouched when nothing is locked: only a non-empty locked side forces the caller's filter through the escape backstop. A profile with injection and no static lock would otherwise AND the tenant clause and skip that check. The rendered query for a caller filtering on another tenant shows the effect:The injected field is absent from the advertised input schema, and FastMCP rejects a
tools/callthat names it before the wrapper runs. It is also left out of the description's field hints, which keeps the rest of the hints rather than suppressing them wholesale.The claim reader
A claim must be a single, non-empty, unpadded string, and anything else refuses the request with
forbiddenbefore any query runs. A list is refused on type, becauseTag(f) == ["a", "b"]renders@f:{a|b}, a genuine union that no inspection of the finished query could tell from a legitimate one.This PR also corrects two things in the reader merged in #738. Measured on Redis 8.4, the query parser splits a tag term on every control character and on the backtick, so a claim such as
\x01acmematched the tenantacmeandacme\tcorpmatchedacme corp. A sweep of U+0000–U+024F, the general punctuation block, and parts of the CJK symbol and fullwidth blocks found exactly 33 such code points, U+0001–U+001F, U+007F and U+0060, and the reader now refuses them. Conversely, the reader no longer refuses|:Tagescapes it within a single value and Redis matches the escaped form literally, so the refusal guarded nothing and turned away real identifiers such as an Auth0 subject,auth0|64f1c2.Startup refusals
Startup refuses an injecting profile wherever the configuration alone shows the guarantee cannot hold:
--allow-unauthenticatedbind, not only stdio.run_asyncnow records the transport, resolving an omitted one the way FastMCP does.search-records,upsert-recordson a writable index, or a profile whoselock.injectdiffers can reach the same indexNOINDEXNOINDEXfilter matches nothing without an error, which looks like correct scoping. Text fields are excluded because a phrase match over tokenised text letsacme-corpalso matchacme-corp-eu.The first three depend only on configuration, so they run before any binding connects and report even with Redis unreachable. A non-case-sensitive injected field warns instead of failing, because Redis folds tag case Unicode-wide unless the field is
CASESENSITIVE, and only the operator knows whether their identity provider emits one canonical form.A changed tool surface after an in-process restart stays a warning, except when injection is configured on either side of the change, where startup now fails. Adding injection to a profile registered without it would otherwise leave that tool serving every tenant while the config says otherwise.
Smaller changes
_validate_custom_toolsbecomes_validate_custom_tools_against_schema, ending its name collision withMCPConfig._validate_custom_tools.build_injected_filterdrops itsschemaparameter, along with a request-time field re-check that read the schema captured at registration and so could never fire. Startup re-validates the freshly inspected schema on every start.Notes
The stdio refusal applies when the server starts through
rvl mcporrun_async, the only points that know the transport. An embedder that callsstartup()directly and serves stdio is not refused at startup, but every call is still refused at request time for lack of a token, so the failure is closed either way.The injected field is withheld from the schema and the hints, not hidden outright. Unless
lock.return_fieldsexcludes it, results carry the field, andlist-indexesdescribes the whole schema. The value shown is only ever the caller's own tenant.The guarantee rests on preconditions the operator owns, and the threat model states them: a genuinely verified token with an asymmetric algorithm, a claim the identity provider assigns, trusted ingestion, and documents stamped with exactly their tenant. The last is subtler than it sounds, because Redis normalises the stored value rather than the claim: it splits on the field's separator, trims surrounding whitespace, and indexes every element of a JSON array, so a document stamped
acme,victimbelongs to both tenants.Release Notes
Custom tool profiles can scope every query to a tenant read from the caller's verified token, with
lock.inject. The injected field is never offered to the model, and the server refuses to start an injecting profile when authentication is off, when it runs over stdio, or when another tool can reach the same index without the tenant scope.Next Steps
Python code tools, planned for the next phase, will receive verified claims through their tool context. They can reuse
build_injected_filterandvalidate_inject_against_schema, which take a plain spec list rather than a configuration model, but composing the result with a caller filter currently lives inline in the profile wrapper. A single composition function, extracted when code tools land, would give them the escape backstop without re-deriving it.