Skip to content

Commit 7e94e4f

Browse files
authored
feat(mcp): optional JWT authentication for HTTP transports (#628)
## Summary Adds **optional JWT authentication** to the RedisVL MCP server's HTTP transports (`streamable-http`, `sse`), plus coarse read/write authorization. Off by default, so existing deployments are unaffected. `stdio` is a local subprocess and is never authenticated. Previously the only access control was the `--read-only` flag (which just hides the upsert tool). Binding the server to a port exposed the index to any client that could reach it. This PR lets operators require a valid bearer token before the server will serve requests. Closes #625. ## How it works The server validates a bearer JWT that an existing identity provider issued (it does not run an OAuth authorization server and does not mint tokens). On each request it checks: - **Signature** against a JWKS endpoint or a static public key - **Issuer** (`iss`) - **Audience** (`aud`), so a token minted for a different service cannot be replayed (RFC 8707) - **Required scopes** to connect, and optionally a **read scope** for `search-records` and a **write scope** for `upsert-records` ### Request flow ```mermaid sequenceDiagram actor User participant IdP as Identity Provider participant MCP as RedisVL MCP Server participant Redis User->>IdP: Authenticate IdP-->>User: Signed JWT (iss, aud, scopes/roles) User->>MCP: MCP request + Bearer JWT MCP->>MCP: Validate signature (JWKS / public key) MCP->>MCP: Check issuer + audience alt token invalid / wrong audience / missing connect scope MCP-->>User: 401 Unauthorized else token valid MCP->>MCP: Gate tool by read / write scope alt scope present MCP->>Redis: Search or upsert (single configured ACL user) Redis-->>MCP: Results MCP-->>User: Tool result else scope missing MCP-->>User: Forbidden end end ``` ### Configurable authorization claim Standard OAuth carries authorization in `scp`/`scope`. Some IdPs (for example Azure AD / Entra) carry app roles in a `roles` claim, which does not appear in the standard scope set. The `authorization_claim` setting (default `scp`) selects which claim the read/write gate reads. ```mermaid flowchart TD A[Validated JWT claims] --> B{authorization_claim} B -->|scp / scope| C["access.scopes"] B -->|roles| D["access.claims.roles"] C --> E[Check read_scope / write_scope] D --> E E -->|present| F[Allow tool] E -->|absent| G[Deny tool] ``` ### Configuration YAML (`server.auth`), with `${ENV}` substitution for secrets: ```yaml server: redis_url: ${REDIS_URL:-redis://localhost:6379} auth: type: jwt jwks_uri: ${MCP_JWKS_URI} # or public_key for a static key issuer: ${MCP_ISSUER} audience: api://redisvl-mcp required_scopes: [kb.read] # required to connect read_scope: kb.search.read # required for search-records write_scope: kb.search.write # required for upsert-records authorization_claim: scp # or roles ``` Every field is also settable via `REDISVL_MCP_AUTH_*` environment variables, which take precedence over YAML. ## What was tested Unit (config validation, provider building, env-over-YAML resolution, scope helpers), integration (real RS256 tokens minted with FastMCP's `RSAKeyPair` and validated against a static public key), and end-to-end over `streamable-http` against real Redis: - no token / garbage token / wrong audience / wrong issuer / expired / missing required scope -> rejected (401) - valid scoped token -> authenticated, lists tools, search succeeds - **read-only token can search but is rejected on upsert; read+write token can upsert** (per-tool scope gating) - **authorization carried in a `roles` claim gates tools correctly** (the Azure AD / Entra style) ### "Can a tenant id from the token drive Redis ACL enforcement?" This was explicitly probed. Using a token shaped like a real enterprise OIDC access token (sanitized below), we confirmed: - the token authenticates (issuer, audience, signature) - its `roles`, `tid`, `oid`, `upn` claims are validated and available to the server - role-based read/write gating works when `authorization_claim: roles` What we found and decided: the `tid` (tenant) claim is **carried but not acted on**. The server holds one Redis connection for one index, so it does not map a tenant/role claim to a per-request Redis ACL user, index, or query filter. That binding belongs in a gateway/policy layer (validate token -> look up claim-to-Redis-identity binding -> inject credentials and filters). See "Out of scope" below. Sanitized token used in tests: ```json { "iss": "https://auth.example/{tenant}/v2.0", "aud": "api://redisvl-mcp", "sub": "nitin", "roles": ["kb.search.read"], "tid": "00000000-0000-0000-0000-000000000000", "scp": "kb.read" } ``` ## Out of scope (intentional, may be future work) - **Per-tenant authorization**: mapping identity claims (tenant id, role) to a specific Redis ACL user, per-tenant index, or injected query filters. This is a gateway/policy concern, not RedisVL. RedisVL provides authentication and coarse read/write gating; fine-grained, per-tenant enforcement sits in front of the server. - **Running a full OAuth authorization server** (`OAuthProvider`). RedisVL validates tokens; it does not issue them. - **Per-vendor login providers** (GitHub, Google, etc.). If interactive OAuth is needed later, a single generic OAuth-proxy option is preferable to one branch per vendor. ## Docs - New how-to guide `docs/user_guide/how_to_guides/mcp_authentication.md` (wired into the how-to index and toctree) with the diagrams above and the gateway-boundary explanation - Updated the `mcp.md` security warning to point at the new guide - Added an Authentication and Authorization section to `concepts/mcp.md` - Enabled `sphinxcontrib-mermaid` so the diagrams render on the docs site ## Notes - Commits are layered (config -> settings -> resolver/provider -> server wiring -> e2e -> scope gating -> docs) and each is self-contained. - `fastmcp` provider imports are deferred so the package stays importable without the optional `mcp` extra. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Changes authentication and authorization on network-exposed MCP endpoints; misconfiguration could block clients or leave HTTP exposed if `--allow-unauthenticated` is used. > > **Overview** > Adds **optional JWT bearer authentication** for RedisVL MCP on HTTP transports (`streamable-http`, `sse`), with coarse read/write tool gating. Auth stays **off by default**; `stdio` is unchanged. > > **Runtime behavior:** New `server.auth` / `REDISVL_MCP_AUTH_*` config resolves at server construction (YAML peek + env overrides) into a FastMCP `JWTVerifier` with stricter required claims (`exp`, `iat`). `search-records` and `upsert-records` call `ensure_tool_scope` using configurable `read_scope` / `write_scope` and optional `authorization_claim` (e.g. `roles`). Startup **fails closed** if auth wired at construction disagrees with the loaded config. > > **CLI hardening:** Binding HTTP to a non-loopback host without JWT now **errors** unless `--allow-unauthenticated` is set; loopback without auth only warns. > > **Docs & tests:** New `mcp_authentication` how-to, concept/run-guide updates, `sphinxcontrib-mermaid`, draft `mcp-auth-spec.md`, and broad unit/integration coverage over JWT validation and live HTTP transport. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f6c5a7ba29842989ac00a69c0e8b6fe09cbf77fb. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 143d753 commit 7e94e4f

25 files changed

Lines changed: 1901 additions & 8 deletions

docs/concepts/mcp.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ RedisVL MCP always registers `search-records`.
8080

8181
Use read-only mode when Redis is serving approved content to assistants and another system owns ingestion.
8282

83+
## Authentication and Authorization
84+
85+
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.
86+
87+
For configuration and the gateway boundary, see {doc}`/user_guide/how_to_guides/mcp_authentication`.
88+
8389
## Tool Surface
8490

8591
RedisVL MCP exposes two tools:

docs/conf.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@
4848
"sphinx_copybutton",
4949
"_extension.gallery_directive",
5050
"myst_nb",
51-
"sphinx_favicon"
51+
"sphinx_favicon",
52+
"sphinxcontrib.mermaid",
5253
]
5354

5455

@@ -91,6 +92,10 @@
9192
myst_enable_extensions = ["colon_fence"]
9293
myst_heading_anchors = 3
9394

95+
# Route ```mermaid fenced blocks to the sphinxcontrib.mermaid directive so they
96+
# render as diagrams on the docs site (they already render natively on GitHub).
97+
myst_fence_as_directive = ["mermaid"]
98+
9499
# Sphinx Book Theme options
95100
html_theme_options = {
96101
"repository_url": "https://github.com/redis/redis-vl-python",

docs/user_guide/how_to_guides/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ How-to guides are **task-oriented** recipes that help you accomplish specific go
4343

4444
- [Manage Indices with the CLI](../cli.ipynb): create, inspect, and delete indices from your terminal
4545
- [Run RedisVL MCP](mcp.md): expose an existing Redis index to MCP clients
46+
- [Authenticate RedisVL MCP](mcp_authentication.md): require JWT bearer tokens and gate read vs write
4647
:::
4748

4849
::::
@@ -65,6 +66,7 @@ How-to guides are **task-oriented** recipes that help you accomplish specific go
6566
| Decide on storage format | [Choose a Storage Type](../05_hash_vs_json.ipynb) |
6667
| Manage indices from terminal | [Manage Indices with the CLI](../cli.ipynb) |
6768
| Expose an index through MCP | [Run RedisVL MCP](mcp.md) |
69+
| Authenticate the MCP server | [Authenticate RedisVL MCP](mcp_authentication.md) |
6870
| Plan and run a supported index migration | [Migrate an Index](migrate-indexes.md) |
6971
| Quantize vectors with resume, rollback, and the wizard | [Migrate an Index: Quantization, Resume, Backup, Wizard](../14_index_migration.ipynb) |
7072

@@ -84,6 +86,7 @@ Cache Embeddings <../10_embeddings_cache>
8486
Use Advanced Query Types <../11_advanced_queries>
8587
Write SQL Queries for Redis <../12_sql_to_redis_queries>
8688
Run RedisVL MCP <mcp>
89+
Authenticate RedisVL MCP <mcp_authentication>
8790
Migrate an Index <migrate-indexes>
8891
Migrate an Index: Quantization, Resume, Backup, Wizard <../14_index_migration>
8992
```

docs/user_guide/how_to_guides/mcp.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,20 @@ Run the server over stdio (default):
4141
uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml
4242
```
4343

44-
Run it over Streamable HTTP for remote MCP clients:
44+
Run it over Streamable HTTP for remote MCP clients. Binding to a non-loopback host (`--host 0.0.0.0`) requires either JWT authentication (see {doc}`mcp_authentication`) or the explicit `--allow-unauthenticated` flag; otherwise the server refuses to start:
4545

4646
```bash
47-
uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport streamable-http --host 0.0.0.0 --port 8000
47+
uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport streamable-http --host 0.0.0.0 --port 8000 --allow-unauthenticated
4848
```
4949

5050
Run it over SSE:
5151

5252
```bash
53-
uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport sse --host 0.0.0.0 --port 9000
53+
uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport sse --host 0.0.0.0 --port 9000 --allow-unauthenticated
5454
```
5555

5656
```{warning}
57-
Streamable HTTP and SSE endpoints are **unauthenticated by default**. Only bind to public interfaces (`--host 0.0.0.0`) on trusted networks or behind an authenticating reverse proxy. When not using `--read-only`, the `upsert-records` tool is also exposed to any client that can reach the server.
57+
Streamable HTTP and SSE endpoints are **unauthenticated by default**. Binding to a non-loopback host without auth fails closed unless you pass `--allow-unauthenticated`; binding to loopback without auth only warns. For real deployments, enable JWT authentication (see {doc}`mcp_authentication`) rather than using `--allow-unauthenticated`. When not using `--read-only`, the `upsert-records` tool is also exposed to any client that can reach the server.
5858
```
5959

6060
Run it in read-only mode to expose search without upsert:
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
---
2+
myst:
3+
html_meta:
4+
"description lang=en": |
5+
How RedisVL MCP authenticates clients with JWT bearer tokens and gates
6+
read vs write access by scope or role claim.
7+
---
8+
9+
# Authenticate RedisVL MCP
10+
11+
This guide explains how the RedisVL MCP server authenticates clients on its HTTP
12+
transports and how it gates read vs write access. It also draws the boundary
13+
between what RedisVL enforces and what belongs in a gateway or policy layer.
14+
15+
```{note}
16+
Authentication applies only to the HTTP transports (`streamable-http`, `sse`).
17+
The `stdio` transport is a local subprocess with no network surface and is never
18+
authenticated.
19+
```
20+
21+
## What RedisVL Enforces
22+
23+
RedisVL validates a bearer **JWT** that an existing identity provider (IdP)
24+
issued. It does not run an OAuth authorization server and does not issue tokens.
25+
26+
On each request it checks:
27+
28+
- **Signature**, against a JWKS endpoint or a static public key.
29+
- **Issuer** (`iss`), so only tokens from your IdP are accepted.
30+
- **Audience** (`aud`), so a token minted for a different service cannot be
31+
replayed against this server (RFC 8707).
32+
- **Expiration**: a present `exp` in the past is rejected, and tokens are
33+
required to carry `exp` and `iat` (configurable via `required_claims`), so a
34+
token with no expiration, which would never expire, is rejected.
35+
- **Required scopes** to connect, and (optionally) a **read scope** to call
36+
`search-records` and a **write scope** to call `upsert-records`.
37+
38+
```{important}
39+
This is **coarse** authorization: it decides whether a caller may connect and
40+
whether it may read or write. It does **not** map token claims to a Redis ACL
41+
user, a per-tenant index, or query filters. See [The Authorization Boundary](#the-authorization-boundary).
42+
```
43+
44+
## OAuth: Which Part RedisVL Handles
45+
46+
In OAuth terms, RedisVL MCP is a **resource server**: it validates access
47+
tokens that your identity provider issued. It does not run an OAuth login flow
48+
and does not issue tokens.
49+
50+
```mermaid
51+
flowchart LR
52+
Client -->|1 - OAuth login flow| IdP[Identity Provider]
53+
IdP -->|2 - issues JWT access token| Client
54+
Client -->|3 - Bearer JWT| MCP[RedisVL MCP]
55+
MCP -->|validates token| Redis[(Redis)]
56+
```
57+
58+
Steps 1 and 2 (obtaining the token) are handled by your client and IdP. RedisVL
59+
only performs the validation in step 3. As a result:
60+
61+
- It works with any OAuth 2.0 / OIDC provider (for example Auth0, Okta, Azure AD
62+
/ Entra, Cognito, Keycloak): point `jwks_uri` at the provider and validate its
63+
tokens.
64+
- RedisVL does not broker interactive "log in with..." flows and does not mint
65+
tokens.
66+
67+
You would only need more than token validation when you want the server itself
68+
to drive an interactive browser login (an OAuth proxy), or to act as its own
69+
authorization server that issues tokens. Both are out of scope today. If
70+
interactive login is ever needed, a single generic `oauth-proxy` option can be
71+
added behind the `auth.type` switch. For enterprise and agent deployments where
72+
the caller already holds a token, JWT validation is sufficient.
73+
74+
## Request Flow
75+
76+
```mermaid
77+
sequenceDiagram
78+
actor User
79+
participant IdP as Identity Provider
80+
participant MCP as RedisVL MCP Server
81+
participant Redis
82+
83+
User->>IdP: Authenticate
84+
IdP-->>User: Signed JWT (iss, aud, scopes/roles)
85+
User->>MCP: MCP request + Bearer JWT
86+
MCP->>MCP: Validate signature (JWKS / public key)
87+
MCP->>MCP: Check issuer + audience
88+
alt token invalid / wrong audience / missing connect scope
89+
MCP-->>User: 401 Unauthorized
90+
else token valid
91+
MCP->>MCP: Gate tool by read / write scope
92+
alt scope present
93+
MCP->>Redis: Search or upsert (single configured ACL user)
94+
Redis-->>MCP: Results
95+
MCP-->>User: Tool result
96+
else scope missing
97+
MCP-->>User: Forbidden
98+
end
99+
end
100+
```
101+
102+
## Configure JWT Authentication
103+
104+
Add a `server.auth` block to your MCP config. Secrets can be injected with
105+
`${ENV}` substitution.
106+
107+
```yaml
108+
server:
109+
redis_url: ${REDIS_URL:-redis://localhost:6379}
110+
auth:
111+
type: jwt
112+
jwks_uri: ${MCP_JWKS_URI} # or set public_key for a static key
113+
issuer: ${MCP_ISSUER}
114+
audience: api://redisvl-mcp
115+
required_scopes: [kb.read] # required to connect
116+
required_claims: [exp, iat] # claims every token must carry (default)
117+
read_scope: kb.search.read # required for search-records
118+
write_scope: kb.search.write # required for upsert-records
119+
120+
indexes:
121+
knowledge:
122+
redis_name: docs_index
123+
search:
124+
type: fulltext
125+
runtime:
126+
text_field_name: content
127+
```
128+
129+
Every field is also settable through `REDISVL_MCP_AUTH_*` environment variables,
130+
which take precedence over the YAML block.
131+
132+
## Choosing the Authorization Claim
133+
134+
Different identity providers carry authorization in different claims. The
135+
default JWT scope claim is `scp` (or `scope`). Some enterprise providers carry
136+
authorization in a **`roles`** claim instead, which does not appear in the
137+
standard scope set.
138+
139+
```mermaid
140+
flowchart TD
141+
A[Validated JWT claims] --> B{authorization_claim}
142+
B -->|scp / scope| C["access.scopes<br/>e.g. kb.read"]
143+
B -->|roles| D["access.claims.roles<br/>e.g. kb.search.read"]
144+
C --> E[Check read_scope / write_scope]
145+
D --> E
146+
E -->|present| F[Allow tool]
147+
E -->|absent| G[Deny tool]
148+
```
149+
150+
Set the claim that holds your authorization values so read and write gating
151+
reads the right place:
152+
153+
```yaml
154+
server:
155+
auth:
156+
type: jwt
157+
# ...
158+
authorization_claim: roles # default: scp
159+
read_scope: kb.search.read
160+
write_scope: kb.search.write
161+
```
162+
163+
A token like the following would then pass the read gate, because
164+
`kb.search.read` is present in `roles`:
165+
166+
```json
167+
{
168+
"iss": "https://your-idp.example/{tenant}/v2.0",
169+
"aud": "api://redisvl-mcp",
170+
"sub": "nitin",
171+
"roles": ["kb.search.read"],
172+
"scp": "kb.read"
173+
}
174+
```
175+
176+
## The Authorization Boundary
177+
178+
RedisVL MCP authenticates the caller and gates read vs write. It does **not**
179+
translate token claims (such as a tenant id or role) into a specific Redis ACL
180+
user, a per-tenant index, or injected query filters. The server holds one Redis
181+
connection for one index, established at startup.
182+
183+
Fine-grained, per-tenant data isolation belongs in a **gateway or policy layer**
184+
in front of the MCP server, which validates the token, looks up a binding of
185+
claim to Redis identity, and injects credentials and filters.
186+
187+
```mermaid
188+
flowchart LR
189+
subgraph Gateway["Gateway / policy layer (out of scope for RedisVL)"]
190+
T[Validate token] --> M["Map claims to<br/>Redis user + index + filters"]
191+
end
192+
subgraph RedisVL["RedisVL MCP (this guide)"]
193+
A[Validate JWT] --> S[Gate read / write by scope]
194+
end
195+
Client --> Gateway --> RedisVL --> Redis[(Redis)]
196+
```
197+
198+
Use RedisVL's JWT validation for authentication and coarse read/write
199+
authorization. Layer a gateway on top when you need per-tenant Redis ACL
200+
enforcement.
201+
202+
## See Also
203+
204+
- {doc}`mcp`: run and configure the RedisVL MCP server.
205+

0 commit comments

Comments
 (0)