Skip to content

feat(mcp): scope custom tool queries to a tenant from a verified token claim - #745

Open
vishal-bala wants to merge 8 commits into
mainfrom
feat/mcp-claim-injection/02-config-enforcement-docs
Open

vishal-bala wants to merge 8 commits into
mainfrom
feat/mcp-claim-injection/02-config-enforcement-docs

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

When tenants share one index, separated by a field such as org_id, exposing search-records makes 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:

custom_tools:
  - name: search-customer-kb
    index: customer_kb
    description: Search this customer's knowledge base.
    lock:
      inject:
        - field: org_id                      # a tag field in the index schema
          from: claim
          claim: "https://acme.example/org"  # the claim name the identity provider emits
          required: true

field and claim are independent names, because the index and the identity provider each choose their own. from must be stated and must be claim, and required accepts only true, because optional injection is unscoped injection. Config load rejects an empty list, a field injected twice, and a field that lock.filter also 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_filter returns 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:

(@org_id:{acme} @org_id:{victim})     # unsatisfiable, so nothing leaks

The injected field is absent from the advertised input schema, and FastMCP rejects a tools/call that 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 forbidden before any query runs. A list is refused on type, because Tag(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 \x01acme matched the tenant acme and acme\tcorp matched acme 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 |: Tag escapes 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:

Refused when Why
Authentication is not enabled, on any transport There is no verified token to read. This covers an unauthenticated loopback bind and any --allow-unauthenticated bind, not only stdio.
Authentication is configured but the server runs over stdio FastMCP never authenticates stdio, so every call would be refused. run_async now records the transport, resolving an omitted one the way FastMCP does.
search-records, upsert-records on a writable index, or a profile whose lock.inject differs can reach the same index Each is an unscoped route past the profile. The scope belongs to the index, so every profile on it must inject the same (field, claim) entries: one scoped by another field, or by the same field from another claim, reads across the tenants the first separates. A write is worse than it looks: hash storage merges a partial record into the existing key, so one tenant could retag another's document as its own.
The injected field is absent, is not a tag, or is NOINDEX A NOINDEX filter matches nothing without an error, which looks like correct scoping. Text fields are excluded because a phrase match over tokenised text lets acme-corp also match acme-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

  • The server's _validate_custom_tools becomes _validate_custom_tools_against_schema, ending its name collision with MCPConfig._validate_custom_tools.
  • The JSON filter DSL clause walker moves from the profile module into config, where load-time validation now needs it.
  • build_injected_filter drops its schema parameter, 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.
  • The clause-containment check merged in feat(mcp): add the security core for auth-claim tenant injection #738 gains the test it lacked.
  • A refusal is logged once: FastMCP already logs the raised error.
  • The concepts page, the MCP how-to guide and the authentication guide document the feature, its startup checks and its threat model, and drop three statements that RedisVL never maps token claims to query filters.

Notes

The stdio refusal applies when the server starts through rvl mcp or run_async, the only points that know the transport. An embedder that calls startup() 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_fields excludes it, results carry the field, and list-indexes describes 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,victim belongs 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_filter and validate_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.

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.
…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
vishal-bala marked this pull request as ready for review October 2, 2026 12:49
@vishal-bala vishal-bala added the auto:minor Increment the minor version when merged label Oct 2, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread redisvl/mcp/server.py Outdated
…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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:minor Increment the minor version when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant