Skip to content
Open
71 changes: 70 additions & 1 deletion docs/concepts/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Use read-only mode when Redis is serving approved content to assistants and anot

## Authentication and Authorization

The HTTP transports can require a JWT bearer token issued by an existing identity provider. The server validates the token signature, issuer, and audience, and can gate read vs write by scope or role claim. This is coarse, per-tool authorization; it does not map token claims to Redis ACL users or per-tenant filters, which remain a gateway concern. The `stdio` transport is local and is never authenticated.
The HTTP transports can require a JWT bearer token issued by an existing identity provider. The server validates the token signature, issuer, and audience, and can gate read vs write by scope or role claim. A custom tool profile can also scope every query to a tenant carried in the token; see [Tenant Scoping From Token Claims](#tenant-scoping-from-token-claims). What the server does not do is map token claims to Redis ACL users or to separate indexes, which remains a gateway concern. The `stdio` transport is local and is never authenticated.

For configuration and the gateway boundary, see {doc}`/user_guide/how_to_guides/mcp_authentication`.

Expand Down Expand Up @@ -197,6 +197,75 @@ Because adding near-duplicate tools makes tool selection harder rather than easi

Misconfiguration fails at startup rather than at the first call. Among the checks: a name colliding with a built-in or using a reserved `redisvl-`/`redisvl_` prefix; a duplicate tool name; a missing or unknown `index`; a `params` key that is not a real argument; `max` on anything but `limit`, or a cap above the binding's `max_limit`; hiding `query`; locking `return_fields` while also exposing them; and a locked filter or projection naming a field the bound index does not have. Unrecognized keys are rejected too, so a typo in `lock` fails loudly instead of silently producing a tool that reads as locked but enforces nothing.

### Tenant Scoping From Token Claims

When several 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. A profile can remove that knob: `lock.inject` reads the tenant from the caller's verified token and AND-combines it into every query the profile runs.

```yaml
custom_tools:
- name: search-customer-kb
index: customer_kb
description: Search this customer's knowledge base.
lock:
inject:
- field: org_id # the tenant field in the index schema
from: claim # the value comes from the verified token
claim: "https://acme.example/org" # the claim name your identity provider emits
required: true
```

`field` and `claim` are independent names: `field` is what the index schema calls the tenant column, and `claim` is what the identity provider calls it. A token carrying `"https://acme.example/org": "acme"` makes every query from that caller run as `@org_id:{acme} AND <everything else>`.

The model cannot set the injected value. The field is absent from the tool's input schema, a call that names it is rejected, and it is left out of the field hints appended to the tool description. It is not hidden outright: unless `lock.return_fields` excludes it, results carry the field, and `list-indexes` describes the whole schema. What the model sees there is only ever its own tenant. If the model filters on the field, its clause ANDs with the injected one, so it can narrow within its own tenant and naming another tenant matches nothing. The rest of the profile works as before, so a static `lock.filter` on another field and a model-supplied filter both still apply.

#### What Counts as a Usable Claim

The claim must be a single, non-empty string. Anything else refuses the request with a `forbidden` error, and no query runs:

| Claim value | Why it is refused |
|---|---|
| Absent, or the request has no token | There is no tenant to scope to. |
| `null` or `""` | An empty tag value would drop the tenant clause from the query entirely. |
| A list, such as `["acme", "victim"]` | It would render as a union, `@org_id:{acme\|victim}`, which spans both tenants. |
| An object, number or boolean | It is not a tenant identifier. |
| Padded with whitespace | It cannot be a real tenant identifier, and refusing is safer than guessing which tenant was meant. |
| Containing a control character or a backtick | The query parser splits a tag term on these, so a value such as `acme` followed by a control character matches the tenant `acme`. |

A `|` inside a single string is accepted. It is escaped, so an identifier such as the Auth0 subject `auth0|64f1c2` matches only its own documents.

A list is refused because of its type, not because of what it renders as. The union it produces is indistinguishable from one a caller could legitimately ask for, so no inspection of the finished query could catch it.

With several `inject` entries, every entry ANDs into the query, and one unusable claim refuses the whole request rather than narrowing by the entries that did resolve.

#### What Fails at Startup

Injection is checked at startup wherever the configuration alone can show it would not hold:

- authentication is not enabled, on any transport, including an unauthenticated loopback HTTP bind and any `--allow-unauthenticated` bind;
- authentication is configured but the server runs over `stdio`, which is never authenticated (checked when the server starts through `rvl mcp` or `run_async`; an embedder that calls `startup()` directly is not, and every call is then refused at request time instead);
- the index is also reachable without the tenant scope: through `search-records`, through `upsert-records` unless the index is read-only, or through another custom tool on the same index that does not inject exactly the same entries, since a tool scoped by another field, or by the same field from another claim, reads across the tenants this one separates;
- the injected field is absent from the bound index, is not a tag field, or is declared `NOINDEX`;
- an `inject` list is empty, names one field twice, or names a field that `lock.filter` also constrains;
- `required` is anything but `true`, or `from` is anything but `claim`.

An injected field must be a tag. Text equality is a phrase match over tokenised text, and text is tokenised on punctuation, so the phrase `acme-corp` would also match `acme-corp-eu`.

The tool set registers once per process. If a restart reloads a configuration that differs from the registered one, the server normally logs a warning and keeps the old tools. When injection is configured on either side of the change, startup fails instead, because keeping the old tenant scoping in force is not something a log line should report.

#### Threat Model

The guarantee is narrow: 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, so the guarantee holds only while these hold:

- The token is genuinely verified. Use a real signing key and an asymmetric algorithm. The server refuses to start an injecting profile without authentication, but it does not check which algorithm you configured.
- The identity provider assigns the claim. If a tenant can mint its own token, or set the claim itself, nothing here stops it reading another tenant's data.
- Only trusted ingestion writes the index. The server refuses `upsert-records` on a writable scoped index, because a write can retag another tenant's document as the writer's own. Whatever loads documents outside the server is inside the trust boundary.
- Every document carries exactly its tenant. Stamp the tenant field on each document, indexed as a tag, with the identifier exactly as the identity provider emits it. Redis normalises the stored value, not the claim: it splits it on the field's separator (`,` by default), so a document stamped `acme,victim` belongs to both tenants; it trims surrounding whitespace; and on JSON storage it indexes every element of an array. A document without the field matches no tenant, so it is invisible rather than shared.
- Tenant identifiers differ by more than case. Tag fields fold case unless declared `CASESENSITIVE`, and the folding is Unicode-wide: `Acme` and `acme` are one tenant, and so are a Kelvin sign and `K`, or composed and decomposed forms of an accented letter. The server warns at startup when an injected field is not case-sensitive.

Where tenants share one index, the injected filter is the only isolation boundary. There is no Redis ACL or keyspace separation behind it, so a defect in filter combination or claim validation is a full cross-tenant read. If you need defence in depth, separate tenants at the Redis layer as well.

Listing the tenant claim under `auth.required_claims` is a cheap outer layer: the verifier then rejects a token that lacks the claim before any tool runs. That check confirms only that the claim is present. Its value is still validated by the profile on every call.

## Why Use MCP Instead of Direct RedisVL Calls

Use RedisVL MCP when you want a standard tool boundary for agent frameworks or assistants that already speak MCP.
Expand Down
85 changes: 85 additions & 0 deletions docs/user_guide/how_to_guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,79 @@ Rules worth knowing:

Misconfiguration fails at startup, not at the first call — a name colliding with a built-in or using a reserved `redisvl-`/`redisvl_` prefix, a duplicate name, a missing or unknown `index`, a cap above `max_limit`, hiding `query`, locking `return_fields` while also exposing them, or a locked filter or projection naming a field the index does not have. Unrecognized keys are rejected too, so a typo in `lock` fails loudly instead of quietly producing a tool that reads as locked but enforces nothing.

### Tenant Scoping With Claim Injection

When tenants share one index, a profile can take the tenant from the caller's verified token instead of trusting the model to pass a filter. This needs authentication, so the example configures it; see {doc}`mcp_authentication` for the rest of that block.

The index needs the tenant on every document, as a tag. Declare it `CASESENSITIVE` unless your identity provider guarantees one case, because tag fields otherwise treat `Acme` and `acme` as the same tenant:

```yaml
# The RedisVL schema the index was created from
index:
name: customer-kb
prefix: kb
fields:
- name: content
type: text
- name: org_id
type: tag
attrs:
case_sensitive: true
```

Then point a profile at it:

```yaml
server:
redis_url: redis://localhost:6379
builtin_tools:
search-records: disabled # both built-ins reach the index unscoped,
upsert-records: disabled # so the server refuses to start with either on
auth:
type: jwt
jwks_uri: ${MCP_JWKS_URI}
issuer: ${MCP_ISSUER}
audience: api://redisvl-mcp
required_claims: [exp, iat, "https://acme.example/org"]

indexes:
customer_kb:
redis_name: customer-kb
search:
type: fulltext
runtime:
text_field_name: content

custom_tools:
- name: search-customer-kb
index: customer_kb
description: Search this customer's knowledge base.
lock:
inject:
- field: org_id
from: claim
claim: "https://acme.example/org"
required: true
```

Serve it over HTTP:

```bash
rvl mcp --config /path/to/mcp_config.yaml --transport streamable-http
```

What the client sees for `search-customer-kb`:

- `query` (required), `limit`, `offset`, `filter` and `return_fields`, with no argument for `org_id`. A call that passes `org_id` anyway is rejected.
- A description whose field hints list `content` but not `org_id`.
- Results from its own tenant only. A `filter` naming `org_id` ANDs with the injected value, so naming another tenant returns nothing. Results still carry `org_id`, always with the caller's own value, unless you lock `return_fields` to leave it out.

The server refuses to start while anything else can reach the same index without the tenant scope: `search-records`, `upsert-records` unless the index is `read_only`, or another custom tool on that index whose `lock.inject` differs, including one with none. Each would hand every caller a way round the profile, and a write could retag another tenant's document as the writer's own. Ingest documents outside the server, stamping `org_id` exactly as the identity provider emits it.

Listing the tenant claim under `required_claims` makes the verifier reject a token without it before any tool runs. That checks presence only: the profile still validates the value on every call, and refuses a missing, empty, list-valued or otherwise unusable claim with a `forbidden` error before any query runs.

The server refuses to start an injecting profile without authentication, over `stdio`, or on a field the index does not hold as an indexed tag. The `stdio` check applies when the server starts through `rvl mcp` or `run_async`. For what the guarantee covers and what it rests on, read the threat model in {doc}`/concepts/mcp`.

## Tool Contracts

RedisVL MCP exposes a small, implementation-owned contract.
Expand Down Expand Up @@ -721,3 +794,15 @@ If the vectorizer dims do not match the configured vector field dims, startup fa
### Hybrid Config Requires Native Runtime Support

Some hybrid params depend on native hybrid support in Redis and redis-py. If your environment does not support that path, remove native-only params such as `knn_ef_runtime` or upgrade Redis and redis-py.

### Claim Injection Requires Authentication

A profile with `lock.inject` refuses to start when authentication is not enabled, or when the server runs over `stdio`, because neither can supply a verified token. Configure `server.auth` and serve over `sse` or `streamable-http`. The check runs before the server connects to Redis, so it reports even when Redis is unreachable.

### Claim Injection Refuses an Unscoped Route

A profile with `lock.inject` refuses to start while `search-records`, `upsert-records` on a writable index, or another custom tool on the same index whose `lock.inject` differs, including one with none. The error lists each route it found, and each tool's injected scope. Disable the built-ins under `server.builtin_tools`, mark the index `read_only`, and give every custom tool on the index the same `lock.inject`.

### Claim Injection Fails Every Request With `forbidden`

The token is verified but its claim is unusable: missing, empty, padded with whitespace, not a single string, or containing a control character or backtick. The error names the claim and the tool. A misspelled `claim` name in the config is the usual cause, since a JWT claim name such as `https://acme.example/org` must match exactly.
24 changes: 9 additions & 15 deletions docs/user_guide/how_to_guides/mcp_authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ On each request it checks:
`search-records` and a **write scope** to call `upsert-records`.

```{important}
This is **coarse** authorization: it decides whether a caller may connect and
whether it may read or write. It does **not** map token claims to a Redis ACL
user, a per-tenant index, or query filters. See [The Authorization Boundary](#the-authorization-boundary).
This is **coarse** authorization: it decides whether a caller may connect and whether it may read or write. It does **not** map token claims to a Redis ACL user or a per-tenant index. A custom tool profile can additionally scope its queries to a tenant claim; see [The Authorization Boundary](#the-authorization-boundary).
```

## OAuth: Which Part RedisVL Handles
Expand Down Expand Up @@ -184,29 +182,24 @@ A token like the following would then pass the read gate, because

## The Authorization Boundary

RedisVL MCP authenticates the caller and gates read vs write. It does **not**
translate token claims (such as a tenant id or role) into a specific Redis ACL
user, a per-tenant index, or injected query filters. The server holds one Redis
connection for one index, established at startup.
RedisVL MCP authenticates the caller, gates read vs write, and can scope every query a custom tool profile runs to a tenant carried in the token. It does **not** translate token claims into a specific Redis ACL user or a per-tenant index: every caller shares the server's Redis connection, established at startup.

Fine-grained, per-tenant data isolation belongs in a **gateway or policy layer**
in front of the MCP server, which validates the token, looks up a binding of
claim to Redis identity, and injects credentials and filters.
To scope queries by tenant inside RedisVL, add `lock.inject` to a profile; see Tenant Scoping With Claim Injection in {doc}`mcp`. That filter is then the only thing separating tenants, so read the threat model in {doc}`/concepts/mcp` before relying on it.

Isolation enforced by Redis itself, rather than by a query filter, belongs in a **gateway or policy layer** in front of the MCP server, which validates the token, looks up a binding of claim to Redis identity, and injects the matching credentials.

```mermaid
flowchart LR
subgraph Gateway["Gateway / policy layer (out of scope for RedisVL)"]
T[Validate token] --> M["Map claims to<br/>Redis user + index + filters"]
T[Validate token] --> M["Map claims to<br/>Redis user + index"]
end
subgraph RedisVL["RedisVL MCP (this guide)"]
A[Validate JWT] --> S[Gate read / write by scope]
A[Validate JWT] --> S[Gate read / write by scope] --> I["Inject tenant filter<br/>(profiles with lock.inject)"]
end
Client --> Gateway --> RedisVL --> Redis[(Redis)]
```

Use RedisVL's JWT validation for authentication and coarse read/write
authorization. Layer a gateway on top when you need per-tenant Redis ACL
enforcement.
Use RedisVL's JWT validation for authentication, read/write authorization, and tenant-scoped queries. Layer a gateway on top when you need per-tenant Redis ACL enforcement.

When such a gateway or reverse proxy terminates the connection and forwards a
rewritten `Host` header, set `server.transport_security.enabled: false` (or
Expand All @@ -216,4 +209,5 @@ proxy's rewritten `Host` is not rejected by the Host/Origin guard.
## See Also

- {doc}`mcp`: run and configure the RedisVL MCP server.
- {doc}`/concepts/mcp`: custom tool profiles, tenant scoping, and its threat model.

Loading
Loading