diff --git a/docs/concepts/mcp.md b/docs/concepts/mcp.md index 9a6970e5..e6d44011 100644 --- a/docs/concepts/mcp.md +++ b/docs/concepts/mcp.md @@ -80,6 +80,12 @@ RedisVL MCP always registers `search-records`. Use read-only mode when Redis is serving approved content to assistants and another system owns ingestion. +## 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. + +For configuration and the gateway boundary, see {doc}`/user_guide/how_to_guides/mcp_authentication`. + ## Tool Surface RedisVL MCP exposes two tools: diff --git a/docs/conf.py b/docs/conf.py index cfac39be..4a5a8775 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,7 +48,8 @@ "sphinx_copybutton", "_extension.gallery_directive", "myst_nb", - "sphinx_favicon" + "sphinx_favicon", + "sphinxcontrib.mermaid", ] @@ -91,6 +92,10 @@ myst_enable_extensions = ["colon_fence"] myst_heading_anchors = 3 +# Route ```mermaid fenced blocks to the sphinxcontrib.mermaid directive so they +# render as diagrams on the docs site (they already render natively on GitHub). +myst_fence_as_directive = ["mermaid"] + # Sphinx Book Theme options html_theme_options = { "repository_url": "https://github.com/redis/redis-vl-python", diff --git a/docs/user_guide/how_to_guides/index.md b/docs/user_guide/how_to_guides/index.md index e9b62da4..91d15b78 100644 --- a/docs/user_guide/how_to_guides/index.md +++ b/docs/user_guide/how_to_guides/index.md @@ -43,6 +43,7 @@ How-to guides are **task-oriented** recipes that help you accomplish specific go - [Manage Indices with the CLI](../cli.ipynb): create, inspect, and delete indices from your terminal - [Run RedisVL MCP](mcp.md): expose an existing Redis index to MCP clients +- [Authenticate RedisVL MCP](mcp_authentication.md): require JWT bearer tokens and gate read vs write ::: :::: @@ -65,6 +66,7 @@ How-to guides are **task-oriented** recipes that help you accomplish specific go | Decide on storage format | [Choose a Storage Type](../05_hash_vs_json.ipynb) | | Manage indices from terminal | [Manage Indices with the CLI](../cli.ipynb) | | Expose an index through MCP | [Run RedisVL MCP](mcp.md) | +| Authenticate the MCP server | [Authenticate RedisVL MCP](mcp_authentication.md) | | Plan and run a supported index migration | [Migrate an Index](migrate-indexes.md) | | Quantize vectors with resume, rollback, and the wizard | [Migrate an Index: Quantization, Resume, Backup, Wizard](../14_index_migration.ipynb) | @@ -84,6 +86,7 @@ Cache Embeddings <../10_embeddings_cache> Use Advanced Query Types <../11_advanced_queries> Write SQL Queries for Redis <../12_sql_to_redis_queries> Run RedisVL MCP +Authenticate RedisVL MCP Migrate an Index Migrate an Index: Quantization, Resume, Backup, Wizard <../14_index_migration> ``` diff --git a/docs/user_guide/how_to_guides/mcp.md b/docs/user_guide/how_to_guides/mcp.md index 5a1755aa..b0a4132b 100644 --- a/docs/user_guide/how_to_guides/mcp.md +++ b/docs/user_guide/how_to_guides/mcp.md @@ -41,20 +41,20 @@ Run the server over stdio (default): uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml ``` -Run it over Streamable HTTP for remote MCP clients: +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: ```bash -uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport streamable-http --host 0.0.0.0 --port 8000 +uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport streamable-http --host 0.0.0.0 --port 8000 --allow-unauthenticated ``` Run it over SSE: ```bash -uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport sse --host 0.0.0.0 --port 9000 +uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp.yaml --transport sse --host 0.0.0.0 --port 9000 --allow-unauthenticated ``` ```{warning} -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. +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. ``` Run it in read-only mode to expose search without upsert: diff --git a/docs/user_guide/how_to_guides/mcp_authentication.md b/docs/user_guide/how_to_guides/mcp_authentication.md new file mode 100644 index 00000000..e09680f7 --- /dev/null +++ b/docs/user_guide/how_to_guides/mcp_authentication.md @@ -0,0 +1,205 @@ +--- +myst: + html_meta: + "description lang=en": | + How RedisVL MCP authenticates clients with JWT bearer tokens and gates + read vs write access by scope or role claim. +--- + +# Authenticate RedisVL MCP + +This guide explains how the RedisVL MCP server authenticates clients on its HTTP +transports and how it gates read vs write access. It also draws the boundary +between what RedisVL enforces and what belongs in a gateway or policy layer. + +```{note} +Authentication applies only to the HTTP transports (`streamable-http`, `sse`). +The `stdio` transport is a local subprocess with no network surface and is never +authenticated. +``` + +## What RedisVL Enforces + +RedisVL validates a bearer **JWT** that an existing identity provider (IdP) +issued. It does not run an OAuth authorization server and does not issue tokens. + +On each request it checks: + +- **Signature**, against a JWKS endpoint or a static public key. +- **Issuer** (`iss`), so only tokens from your IdP are accepted. +- **Audience** (`aud`), so a token minted for a different service cannot be + replayed against this server (RFC 8707). +- **Expiration**: a present `exp` in the past is rejected, and tokens are + required to carry `exp` and `iat` (configurable via `required_claims`), so a + token with no expiration, which would never expire, is rejected. +- **Required scopes** to connect, and (optionally) a **read scope** to call + `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). +``` + +## OAuth: Which Part RedisVL Handles + +In OAuth terms, RedisVL MCP is a **resource server**: it validates access +tokens that your identity provider issued. It does not run an OAuth login flow +and does not issue tokens. + +```mermaid +flowchart LR + Client -->|1 - OAuth login flow| IdP[Identity Provider] + IdP -->|2 - issues JWT access token| Client + Client -->|3 - Bearer JWT| MCP[RedisVL MCP] + MCP -->|validates token| Redis[(Redis)] +``` + +Steps 1 and 2 (obtaining the token) are handled by your client and IdP. RedisVL +only performs the validation in step 3. As a result: + +- It works with any OAuth 2.0 / OIDC provider (for example Auth0, Okta, Azure AD + / Entra, Cognito, Keycloak): point `jwks_uri` at the provider and validate its + tokens. +- RedisVL does not broker interactive "log in with..." flows and does not mint + tokens. + +You would only need more than token validation when you want the server itself +to drive an interactive browser login (an OAuth proxy), or to act as its own +authorization server that issues tokens. Both are out of scope today. If +interactive login is ever needed, a single generic `oauth-proxy` option can be +added behind the `auth.type` switch. For enterprise and agent deployments where +the caller already holds a token, JWT validation is sufficient. + +## 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 +``` + +## Configure JWT Authentication + +Add a `server.auth` block to your MCP config. Secrets can be injected with +`${ENV}` substitution. + +```yaml +server: + redis_url: ${REDIS_URL:-redis://localhost:6379} + auth: + type: jwt + jwks_uri: ${MCP_JWKS_URI} # or set public_key for a static key + issuer: ${MCP_ISSUER} + audience: api://redisvl-mcp + required_scopes: [kb.read] # required to connect + required_claims: [exp, iat] # claims every token must carry (default) + read_scope: kb.search.read # required for search-records + write_scope: kb.search.write # required for upsert-records + +indexes: + knowledge: + redis_name: docs_index + search: + type: fulltext + runtime: + text_field_name: content +``` + +Every field is also settable through `REDISVL_MCP_AUTH_*` environment variables, +which take precedence over the YAML block. + +## Choosing the Authorization Claim + +Different identity providers carry authorization in different claims. The +default JWT scope claim is `scp` (or `scope`). Some enterprise providers carry +authorization in a **`roles`** claim instead, which does not appear in the +standard scope set. + +```mermaid +flowchart TD + A[Validated JWT claims] --> B{authorization_claim} + B -->|scp / scope| C["access.scopes
e.g. kb.read"] + B -->|roles| D["access.claims.roles
e.g. kb.search.read"] + C --> E[Check read_scope / write_scope] + D --> E + E -->|present| F[Allow tool] + E -->|absent| G[Deny tool] +``` + +Set the claim that holds your authorization values so read and write gating +reads the right place: + +```yaml +server: + auth: + type: jwt + # ... + authorization_claim: roles # default: scp + read_scope: kb.search.read + write_scope: kb.search.write +``` + +A token like the following would then pass the read gate, because +`kb.search.read` is present in `roles`: + +```json +{ + "iss": "https://your-idp.example/{tenant}/v2.0", + "aud": "api://redisvl-mcp", + "sub": "nitin", + "roles": ["kb.search.read"], + "scp": "kb.read" +} +``` + +## 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. + +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. + +```mermaid +flowchart LR + subgraph Gateway["Gateway / policy layer (out of scope for RedisVL)"] + T[Validate token] --> M["Map claims to
Redis user + index + filters"] + end + subgraph RedisVL["RedisVL MCP (this guide)"] + A[Validate JWT] --> S[Gate read / write by scope] + 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. + +## See Also + +- {doc}`mcp`: run and configure the RedisVL MCP server. + diff --git a/mcp-auth-spec.md b/mcp-auth-spec.md new file mode 100644 index 00000000..53388426 --- /dev/null +++ b/mcp-auth-spec.md @@ -0,0 +1,272 @@ +# Spec: Authentication for the RedisVL MCP Server + +**Status:** Draft +**Date:** 2026-06-09 +**Owner:** RedisVL MCP +**Tracking issue:** redis/redis-vl-python#625 +**Related:** `redisvl/mcp/`, FastMCP auth docs (https://gofastmcp.com/servers/auth/authentication) + +--- + +## 1. Background + +RedisVL ships an MCP server (`redisvl/mcp/`) built as a `FastMCP` subclass that +exposes one configured Redis index to MCP clients for search and optional +upsert. + +Today the server is constructed with **no authentication**: + +```python +# redisvl/mcp/server.py:73 +super().__init__("redisvl", lifespan=self._fastmcp_lifespan) +``` + +The only access control is the `--read-only` flag, which simply skips +registration of the `upsert-records` tool (`redisvl/mcp/server.py:195-203`). It +is **not** a transport-level control. + +The CLI (`redisvl/cli/mcp.py`) can bind the server to a network port via the +`sse` and `streamable-http` transports (`--host`, `--port`). In that mode, any +client that can reach the port has full access to the index. This is the gap +this spec closes. + +### Current relevant facts + +- `fastmcp >= 2.0.0` is the declared optional dependency (`pyproject.toml`, + extra `mcp`). Locally installed: **fastmcp 3.1.0**. +- FastMCP's constructor accepts an `auth=` provider (verified). +- `JWTVerifier` lives at `fastmcp.server.auth.providers.jwt.JWTVerifier` with + signature `(public_key, jwks_uri, issuer, audience, algorithm, + required_scopes, base_url, ssrf_safe, http_client)`. +- The same module exports `RSAKeyPair` (`.generate()`, `.create_token(...)`), + which we use to mint real signed JWTs in tests. + +--- + +## 2. Goals / Non-goals + +### Goals + +1. Require authentication for the **HTTP transports** (`sse`, + `streamable-http`), opt-in and off by default. +2. Default mechanism: **JWT bearer-token validation** (`JWTVerifier`) against an + existing IdP's JWKS endpoint or a static public key. No OAuth authorization + server. +3. Validate the **audience (`aud`)** claim, not just signature and issuer, so a + token minted for another service cannot be replayed (RFC 8707). +4. **Scope-gate read vs write**: a token's scope/role claim decides whether the + caller can reach the upsert tool, not just whether it can connect. +5. Config-driven and import-safe: optional, never breaks installs without the + `mcp` extra; secrets stay out of the command line. +6. Make the insecure case loud: warn (or fail closed) when an HTTP transport is + bound with no auth. + +### Non-goals + +- Authenticating `stdio` (local subprocess, no network surface; FastMCP ignores + auth there). +- **Per-tenant / fine-grained authorization** that maps identity claims (e.g. a + tenant id or role) to a specific Redis ACL user, key-prefix fence, or injected + query filter. See §3.4. This belongs in a gateway/policy layer. +- Running a full OAuth authorization server (`OAuthProvider`). +- Per-vendor login providers (GitHub, Google, ...). A single generic + `oauth-proxy` option is preferred later over one branch per vendor. + +--- + +## 3. Design + +### 3.1 Auth tiers + +| Tier | Provider | When | +|------|----------|------| +| **JWT validation** (default, phase 1) | `JWTVerifier` | An existing IdP/key issues JWTs; validate issuer/audience/scopes against JWKS or static key. | +| **OAuth proxy** (phase 2) | generic `oauth-proxy` over `OAuthProxy` | Interactive login through an existing provider, no class-per-vendor. | +| **None** (default) | - | No auth. Allowed, but warns/fails on HTTP transports. | + +Phase 1 ships `jwt` + `none`. The `auth.type` switch is designed so +`oauth-proxy` slots in later without breaking changes. + +### 3.2 Lifecycle constraint + +The auth provider must be passed to `super().__init__()` (`server.py:73`), i.e. +**at construction time**, but YAML config is loaded later in `startup()` -> +`_initialize_runtime_resources()` (`server.py:300-302`). Resolution: a small +standalone helper reads auth config from `MCPSettings` (env) and, if a config +path is set, peeks only the `server.auth` block, without running full startup. + +### 3.3 Transport interaction + +- `stdio`: provider not attached; no warning. +- `sse` / `streamable-http`: provider attached if configured. If not, the CLI + warns loudly, and fails closed on non-loopback (`0.0.0.0`) binds unless + `--allow-unauthenticated` is passed. + +### 3.4 Scope gating, and the per-tenant boundary + +Phase 1 enforces **coarse** authorization at the server: + +- A configured **read scope** is required to call `search-records`. +- A configured **write scope** is required to call `upsert-records` (in + addition to the server not being `--read-only`). + +What phase 1 deliberately does **not** do: map a token's tenant/role claims to a +*different Redis identity per request*. The server holds one Redis connection +for one index, established at startup. Re-authenticating to Redis as a +per-request ACL user derived from the token is a much larger change and is the +job of a gateway/policy layer that sits in front of the MCP server (validate +token -> look up binding table -> inject Redis credentials + query filters). +RedisVL provides the coarse token validation and scope gate; the gateway owns +fine-grained, per-tenant enforcement. This may be revisited as future work. + +--- + +## 4. Config schema + +### 4.1 Env vars (`MCPSettings`, prefix `REDISVL_MCP_`) + +| Env var | Field | Notes | +|---------|-------|-------| +| `REDISVL_MCP_AUTH_TYPE` | `auth_type` | `none` (default) \| `jwt` | +| `REDISVL_MCP_AUTH_JWKS_URI` | `auth_jwks_uri` | JWKS endpoint | +| `REDISVL_MCP_AUTH_PUBLIC_KEY` | `auth_public_key` | Static PEM (alternative to JWKS) | +| `REDISVL_MCP_AUTH_ISSUER` | `auth_issuer` | Expected `iss` | +| `REDISVL_MCP_AUTH_AUDIENCE` | `auth_audience` | Expected `aud` (required for jwt) | +| `REDISVL_MCP_AUTH_ALGORITHM` | `auth_algorithm` | Default `RS256` | +| `REDISVL_MCP_AUTH_REQUIRED_SCOPES` | `auth_required_scopes` | Comma-separated connect scopes | +| `REDISVL_MCP_AUTH_READ_SCOPE` | `auth_read_scope` | Scope required for search | +| `REDISVL_MCP_AUTH_WRITE_SCOPE` | `auth_write_scope` | Scope required for upsert | +| `REDISVL_MCP_AUTH_BASE_URL` | `auth_base_url` | Public server URL | + +### 4.2 YAML (optional `server.auth` block) + +```yaml +server: + redis_url: ${REDIS_URL:-redis://localhost:6379} + auth: + type: jwt # none | jwt + jwks_uri: ${MCP_JWKS_URI} + issuer: ${MCP_ISSUER} + audience: api://redisvl-mcp + algorithm: RS256 + required_scopes: [kb.read] + read_scope: kb.search.read + write_scope: kb.search.write + # public_key: ${MCP_PUBLIC_KEY} # use instead of jwks_uri for static keys +``` + +`${ENV}` / `${ENV:-default}` substitution already works via +`redisvl/mcp/config.py` (`_ENV_PATTERN`, line 13). Env vars override YAML. + +### 4.3 Validation rules + +- `type: jwt` requires **exactly one** of `jwks_uri` or `public_key`. +- `type: jwt` requires `audience` (reject unbounded audience). +- `type: jwt` should set `issuer` (warn if missing). +- Unknown `type` -> config error at load time. +- `read_scope` / `write_scope` optional; when set, gate the respective tool. + +--- + +## 5. Implementation plan + +- `redisvl/mcp/config.py`: add `MCPAuthConfig` (+ validators) and optional + `auth: MCPAuthConfig | None` on `MCPServerConfig`. +- `redisvl/mcp/settings.py`: add `auth_*` fields, plumb through `from_env`. +- New `redisvl/mcp/auth.py`: + - `resolve_auth_config(settings, config_path) -> MCPAuthConfig | None` + (env over YAML peek). + - `build_auth_provider(auth_config) -> Any | None` returning `None` for + `none`, a configured `JWTVerifier` for `jwt`; provider imports guarded for + the optional `mcp` extra. + - `peek_yaml_auth(config_path) -> dict | None` reads only `server.auth` with + env substitution. +- `redisvl/mcp/server.py`: build the provider in `__init__`, pass `auth=`; + store `self._auth_enabled`; apply read/write scope gates at tool registration. +- `redisvl/cli/mcp.py`: warn / fail-closed on HTTP transport without auth; + add `--allow-unauthenticated`. +- `pyproject.toml`: confirm/raise the `fastmcp` floor that guarantees + `JWTVerifier`. + +--- + +## 6. Test plan (TDD) + +Tests are written first; implementation follows until green. + +### 6.1 Unit (`tests/unit/test_mcp/`) + +`test_auth_config.py` +- `none`/unset -> resolves to no auth. +- `jwt` with `jwks_uri` + `issuer` + `audience` -> valid config. +- `jwt` with both `jwks_uri` and `public_key` -> error. +- `jwt` with neither `jwks_uri` nor `public_key` -> error. +- `jwt` missing `audience` -> error. +- unknown `type` -> error. + +`test_auth_provider.py` +- `build_auth_provider(none)` -> `None`. +- `build_auth_provider(jwt)` -> instance of `JWTVerifier` carrying issuer, + audience, required scopes. +- import-safe behavior when `fastmcp` is unavailable. + +`test_settings.py` (extend) +- `REDISVL_MCP_AUTH_*` env vars populate the new fields. +- explicit `from_env` args override env. + +`test_auth_resolution.py` +- env overrides YAML `server.auth`. +- YAML-only `server.auth` is honored when env is unset. + +### 6.2 Integration (`tests/integration/test_mcp/test_auth.py`) + +Uses `RSAKeyPair` to mint real RS256 tokens and a `JWTVerifier` configured with +the key pair's public key (no network JWKS needed). + +Canonical token fixture (sanitized; see §6.3): +- valid read token -> `search-records` succeeds. +- valid write token -> `upsert-records` succeeds. +- read-only token -> `upsert-records` rejected (insufficient scope). +- wrong `aud` -> rejected. +- wrong `iss` -> rejected. +- expired token -> rejected. +- no/garbage token -> rejected (401). +- no-auth server -> any request succeeds (back-compat). + +### 6.3 Canonical token fixture (sanitized) + +Modeled on a real enterprise OIDC access token, with all org-specific values +replaced by dummies (`nitin`, org `redis`): + +```jsonc +{ + "iss": "https://auth.redis.example/abc123/v2.0", + "aud": "api://redisvl-mcp", + "oid": "00000000-nitin-0000-000000000000", + "upn": "nitin@redis.example", + "tid": "11111111-2222-3333-4444-555555555555", + "roles": ["kb.search.read"], + "scp": "kb.read" +} +``` + +The write-path test uses `roles: ["kb.search.write"]` / appropriate scope. The +`tid`/`oid`/`upn` claims are carried but **not** acted on by the server in phase +1 (they would drive a gateway binding table; see §3.4). + +--- + +## 7. Docs + +- Add an Authentication section to `docs/user_guide/15_mcp.ipynb`: the + `server.auth` block, `REDISVL_MCP_AUTH_*` env vars, a worked `JWTVerifier` + example, scope gating, and the security note that HTTP transports must not be + exposed unauthenticated. Note `stdio` needs no auth. + +--- + +## 8. Rollout + +- Backward compatible: auth defaults to `none`; existing deployments unaffected + except the new HTTP-without-auth warning. +- Phase 1: JWT + None + scope gating + warning. Phase 2: generic `oauth-proxy`. diff --git a/pyproject.toml b/pyproject.toml index 7be93813..ce177578 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ docs = [ "sphinx-favicon>=1.0.1,<2", "sphinx-design>=0.5.0,<0.6", "myst-nb>=1.1.0,<2", + "sphinxcontrib-mermaid>=0.9.2,<1", ] [tool.uv] diff --git a/redisvl/cli/mcp.py b/redisvl/cli/mcp.py index 3eb8d814..31f98809 100644 --- a/redisvl/cli/mcp.py +++ b/redisvl/cli/mcp.py @@ -65,6 +65,13 @@ def __init__(self): type=int, default=8000, ) + parser.add_argument( + "--allow-unauthenticated", + help="Allow binding an HTTP transport to a non-loopback host without auth", + action="store_true", + dest="allow_unauthenticated", + default=False, + ) args = parser.parse_args(sys.argv[2:]) self._run(args) @@ -85,6 +92,7 @@ def _run(self, args): transport=args.transport, host=args.host, port=args.port, + allow_unauthenticated=args.allow_unauthenticated, ) ) except KeyboardInterrupt: @@ -112,8 +120,49 @@ def _run_awaitable(awaitable): """Bridge the synchronous CLI entrypoint to async server lifecycle code.""" return asyncio.run(awaitable) - async def _serve(self, server, transport="stdio", host="127.0.0.1", port=8000): + _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) + + @classmethod + def _check_http_auth(cls, transport, host, auth_enabled, allow_unauthenticated): + """Return a warning for an unauthenticated HTTP bind, or raise if unsafe. + + No-op for stdio or when auth is enabled. Warns for loopback binds and + fails closed for non-loopback binds unless explicitly allowed. + """ + if transport not in ("sse", "streamable-http") or auth_enabled: + return None + + if host not in cls._LOOPBACK_HOSTS and not allow_unauthenticated: + raise RuntimeError( + f"Refusing to bind an unauthenticated MCP server to non-loopback " + f"host '{host}'. Configure auth (REDISVL_MCP_AUTH_* or the " + f"server.auth config block) or pass --allow-unauthenticated." + ) + + return ( + f"WARNING: serving MCP over {transport} on {host} without " + f"authentication. Any client that can reach this address has full " + f"access. Configure auth or restrict network access." + ) + + async def _serve( + self, + server, + transport="stdio", + host="127.0.0.1", + port=8000, + allow_unauthenticated=False, + ): """Run startup, serving, and shutdown on one event loop.""" + warning = self._check_http_auth( + transport, + host, + getattr(server, "_auth_enabled", False), + allow_unauthenticated, + ) + if warning: + print(warning, file=sys.stderr) + transport_kwargs = {} if transport in ("sse", "streamable-http"): transport_kwargs["host"] = host diff --git a/redisvl/mcp/auth.py b/redisvl/mcp/auth.py new file mode 100644 index 00000000..9ab6152c --- /dev/null +++ b/redisvl/mcp/auth.py @@ -0,0 +1,192 @@ +"""Authentication wiring for the RedisVL MCP server. + +Resolves an :class:`~redisvl.mcp.config.MCPAuthConfig` from environment +variables (``REDISVL_MCP_AUTH_*``) and/or the YAML ``server.auth`` block, and +builds a FastMCP auth provider from it. Env vars take precedence over YAML. + +Auth applies only to HTTP transports; ``stdio`` is never authenticated. FastMCP +imports are deferred so this module stays importable without the ``mcp`` extra. +""" + +from pathlib import Path +from typing import Any + +import yaml + +from redisvl.mcp.config import MCPAuthConfig, _substitute_env +from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError +from redisvl.mcp.settings import MCPSettings + + +def peek_yaml_auth(config_path: str | None) -> dict[str, Any] | None: + """Read only the ``server.auth`` block from the YAML config, env-substituted. + + Returns ``None`` when the path is unset/missing or no auth block is present. + This intentionally avoids the full runtime config load so auth can be wired + at construction time, before the server lifespan runs. + """ + if not config_path: + return None + path = Path(config_path).expanduser() + if not path.exists(): + return None + try: + with path.open("r", encoding="utf-8") as file: + raw = yaml.safe_load(file) + except yaml.YAMLError: + return None + + server = raw.get("server") if isinstance(raw, dict) else None + auth = server.get("auth") if isinstance(server, dict) else None + if not isinstance(auth, dict): + return None + return _substitute_env(auth) + + +def resolve_auth_config( + settings: MCPSettings, config_path: str | None = None +) -> MCPAuthConfig | None: + """Resolve the effective auth config from env (preferred) over YAML. + + Returns ``None`` when no auth is configured or the resolved type is + ``none``. + """ + env_auth = settings.auth_overrides() + + # An explicit env type=none disables auth, overriding any YAML auth block. + if env_auth.get("type") == "none": + return None + + yaml_auth = peek_yaml_auth(config_path) or {} + merged: dict[str, Any] = {**yaml_auth, **env_auth} + if not merged: + return None + + config = MCPAuthConfig.model_validate(merged) + if config.type == "none": + return None + return config + + +def missing_required_claims(claims: Any, required_claims: Any) -> list: + """Return the configured claims absent from a token's claims mapping.""" + claims = claims or {} + return [claim for claim in (required_claims or ()) if claim not in claims] + + +def build_auth_provider(auth_config: MCPAuthConfig | None) -> Any | None: + """Build a FastMCP auth provider from an `MCPAuthConfig`. + + Returns ``None`` for ``None`` / ``type == "none"``. For ``jwt`` returns a + configured ``JWTVerifier``. The provider import is deferred so importing this + module never requires the optional ``mcp`` extra. + """ + if auth_config is None or auth_config.type == "none": + return None + + if auth_config.type == "jwt": + try: + from fastmcp.server.auth.providers.jwt import JWTVerifier + except ImportError as exc: # pragma: no cover - exercised without extra + raise RuntimeError( + "JWT authentication requires the optional MCP dependencies. " + "Install them with `pip install redisvl[mcp]`." + ) from exc + + required_claims = tuple(auth_config.required_claims or ()) + + class _StrictClaimsJWTVerifier( + JWTVerifier + ): # pylint: disable=too-few-public-methods + """JWTVerifier that also requires specific claims to be present. + + FastMCP's verifier only rejects an ``exp`` that is present and past, + so a token without ``exp`` would never expire. Requiring ``exp`` + (and ``iat``) closes that gap. + """ + + async def load_access_token(self, token: str): + access = await super().load_access_token(token) + if access is None: + return None + if missing_required_claims(access.claims, required_claims): + return None + return access + + return _StrictClaimsJWTVerifier( + public_key=auth_config.public_key, + jwks_uri=auth_config.jwks_uri, + issuer=auth_config.issuer, + audience=auth_config.audience, + algorithm=auth_config.algorithm, + required_scopes=auth_config.required_scopes or None, + base_url=auth_config.base_url, + ) + + raise ValueError(f"Unsupported auth type: {auth_config.type}") + + +def authorization_values(access_token: Any, authorization_claim: str = "scp") -> list: + """Return the authorization values a token carries for the given claim. + + Standard OAuth scopes (``scp``/``scope``) are read from the verifier-parsed + ``access_token.scopes``. Any other claim (for example ``roles``) is read + from ``access_token.claims`` and normalized to a list, accepting either a + list or a space-delimited string. + """ + if authorization_claim in ("scp", "scope"): + return list(getattr(access_token, "scopes", None) or []) + + claims = getattr(access_token, "claims", None) or {} + raw = claims.get(authorization_claim) + if isinstance(raw, str): + return raw.split() + if isinstance(raw, (list, tuple)): + return [str(value) for value in raw] + return [] + + +def token_has_scope( + access_token: Any, scope: str | None, authorization_claim: str = "scp" +) -> bool: + """Return whether an access token carries the given scope. + + A ``None`` scope means no gate is configured, so access is allowed. + """ + if scope is None: + return True + return scope in authorization_values(access_token, authorization_claim) + + +def ensure_tool_scope(server: Any, required_scope: str | None) -> None: + """Raise if the current request's token lacks the required tool scope. + + No-ops when auth is disabled or no scope is configured. Otherwise reads the + current access token and checks the configured authorization claim, raising + a ``forbidden`` MCP error when the scope is absent. + """ + auth_config = getattr(server, "auth_config", None) + if ( + not getattr(server, "_auth_enabled", False) + or auth_config is None + or required_scope is None + ): + return + + from fastmcp.server.dependencies import get_access_token + + access_token = get_access_token() + if access_token is None: + # No authenticated request context (for example the local stdio + # transport, which FastMCP never authenticates). Authenticated HTTP + # transports reject tokenless requests before the tool runs, so a + # missing token here means the scope gate does not apply. + return + + claim = getattr(auth_config, "authorization_claim", "scp") + if not token_has_scope(access_token, required_scope, claim): + raise RedisVLMCPError( + f"Token is missing the required scope '{required_scope}'", + code=MCPErrorCode.FORBIDDEN, + retryable=False, + ) diff --git a/redisvl/mcp/config.py b/redisvl/mcp/config.py index d1ae8cbc..65f53060 100644 --- a/redisvl/mcp/config.py +++ b/redisvl/mcp/config.py @@ -90,10 +90,86 @@ def to_init_kwargs(self) -> dict[str, Any]: return {"model": self.model, **self.extra_kwargs} +class MCPAuthConfig(BaseModel): + """Authentication configuration for the MCP server's HTTP transports. + + Auth is optional and defaults to ``none``. When ``type`` is ``jwt`` the + server validates incoming bearer tokens against either a JWKS endpoint or a + static public key, checking issuer, audience, and required scopes. ``stdio`` + is a local subprocess and is never authenticated regardless of this config. + """ + + type: Literal["none", "jwt"] = "none" + + # JWT validation + jwks_uri: str | None = Field(default=None, min_length=1) + public_key: str | None = Field(default=None, min_length=1) + issuer: str | None = Field(default=None, min_length=1) + audience: str | None = Field(default=None, min_length=1) + algorithm: str | None = Field(default=None, min_length=1) + required_scopes: list[str] = Field(default_factory=list) + # Claims that must be present on every token. Defaults to exp and iat so a + # token without an expiration (which would never expire) is rejected. + required_claims: list[str] = Field(default_factory=lambda: ["exp", "iat"]) + base_url: str | None = Field(default=None, min_length=1) + + # Coarse read/write scope gating (enforced per tool call). + read_scope: str | None = Field(default=None, min_length=1) + write_scope: str | None = Field(default=None, min_length=1) + # Token claim that carries authorization values. Standard OAuth uses + # ``scp``/``scope``; some IdPs (for example Azure AD / Entra) carry app + # roles in a ``roles`` claim instead. + authorization_claim: str = Field(default="scp", min_length=1) + + @model_validator(mode="after") + def _validate_jwt(self) -> "MCPAuthConfig": + """Enforce the JWT key-source, issuer, and audience requirements.""" + jwt_fields_set = any( + ( + self.jwks_uri, + self.public_key, + self.issuer, + self.audience, + self.required_scopes, + self.read_scope, + self.write_scope, + ) + ) + if self.type != "jwt": + # Fail loudly rather than silently running unauthenticated when an + # auth block looks like JWT but forgot to set the type. + if jwt_fields_set: + raise ValueError( + "auth has JWT settings but type is not 'jwt'; set type: jwt " + "to enable authentication" + ) + return self + + has_jwks = self.jwks_uri is not None + has_key = self.public_key is not None + if has_jwks == has_key: + raise ValueError( + "auth.type 'jwt' requires exactly one of jwks_uri or public_key" + ) + if self.issuer is None: + raise ValueError( + "auth.type 'jwt' requires issuer; without it the verifier would " + "accept tokens from any issuer" + ) + if self.audience is None: + raise ValueError( + "auth.type 'jwt' requires audience to bind tokens to this server " + "(RFC 8707); an unbounded audience would accept tokens minted for " + "other services" + ) + return self + + class MCPServerConfig(BaseModel): """Server-level bootstrap configuration.""" redis_url: str = Field(..., min_length=1) + auth: MCPAuthConfig | None = None class MCPIndexSearchConfig(BaseModel): diff --git a/redisvl/mcp/errors.py b/redisvl/mcp/errors.py index e1a6313d..aae49576 100644 --- a/redisvl/mcp/errors.py +++ b/redisvl/mcp/errors.py @@ -13,6 +13,7 @@ class MCPErrorCode(str, Enum): INVALID_REQUEST = "invalid_request" INVALID_FILTER = "invalid_filter" + FORBIDDEN = "forbidden" DEPENDENCY_MISSING = "dependency_missing" BACKEND_UNAVAILABLE = "backend_unavailable" INTERNAL_ERROR = "internal_error" diff --git a/redisvl/mcp/server.py b/redisvl/mcp/server.py index 4a7484df..a67618df 100644 --- a/redisvl/mcp/server.py +++ b/redisvl/mcp/server.py @@ -2,12 +2,14 @@ from contextlib import asynccontextmanager from enum import Enum, auto from importlib import import_module +from pathlib import Path from typing import Any, Awaitable from redis import __version__ as redis_py_version from redisvl.exceptions import RedisSearchError from redisvl.index import AsyncSearchIndex +from redisvl.mcp.auth import build_auth_provider, resolve_auth_config from redisvl.mcp.config import MCPConfig, load_mcp_config from redisvl.mcp.settings import MCPSettings from redisvl.mcp.tools.search import register_search_tool @@ -70,7 +72,19 @@ def __init__(self, settings: MCPSettings): self._active_requests_drained.set() self._fastmcp_lifespan = self._server_lifespan # FastMCP startup/shutdown hook - super().__init__("redisvl", lifespan=self._fastmcp_lifespan) + # Resolve the config path to an absolute path so a later working-directory + # change cannot make the construction-time and startup-time reads diverge. + self._config_path = str(Path(settings.config).expanduser().resolve()) + + # Auth is resolved at construction time (FastMCP needs the provider in + # its constructor), reading env vars and peeking the YAML server.auth + # block without running full startup. Applies only to HTTP transports. + auth_config = resolve_auth_config(settings, self._config_path) + auth_provider = build_auth_provider(auth_config) + self.auth_config = auth_config + self._auth_enabled = auth_provider is not None + + super().__init__("redisvl", lifespan=self._fastmcp_lifespan, auth=auth_provider) async def startup(self) -> None: """Load config, inspect the configured index, and initialize dependencies.""" @@ -297,9 +311,28 @@ async def _wait_for_active_requests(self) -> None: """Wait for already-admitted guarded requests to finish.""" await self._active_requests_drained.wait() + def _verify_auth_not_stale(self) -> None: + """Fail closed if startup-time auth disagrees with what was wired. + + The auth provider must be passed to FastMCP at construction, before the + full config is loaded. If the config file was unreadable then (for + example created after construction), auth could be silently disabled + while the loaded config enables it. Refuse to serve rather than expose + an unauthenticated HTTP transport. + """ + expected = resolve_auth_config(self.mcp_settings, self._config_path) + if (expected is not None) != self._auth_enabled: + raise RuntimeError( + "MCP auth configuration changed between server construction and " + "startup, so the wired auth state is stale. Refusing to start to " + "avoid serving unauthenticated. Use an absolute config path and " + "ensure the config file exists before constructing the server." + ) + async def _initialize_runtime_resources(self) -> Any: """Load config and initialize the Redis-backed runtime dependencies.""" - self.config = load_mcp_config(self.mcp_settings.config) + self.config = load_mcp_config(self._config_path) + self._verify_auth_not_stale() self._semaphore = asyncio.Semaphore(self.config.runtime.max_concurrency) self._supports_native_hybrid_search = None timeout = self.config.runtime.startup_timeout_seconds diff --git a/redisvl/mcp/settings.py b/redisvl/mcp/settings.py index 5aca7432..62c9ec68 100644 --- a/redisvl/mcp/settings.py +++ b/redisvl/mcp/settings.py @@ -17,6 +17,22 @@ class MCPSettings(BaseSettings): tool_search_description: str | None = None tool_upsert_description: str | None = None + # Authentication overrides (``REDISVL_MCP_AUTH_*``). When any are set they + # take precedence over the YAML ``server.auth`` block. ``auth_type`` of + # ``None`` means "fall back to YAML / no auth". + auth_type: str | None = None + auth_jwks_uri: str | None = None + auth_public_key: str | None = None + auth_issuer: str | None = None + auth_audience: str | None = None + auth_algorithm: str | None = None + auth_required_scopes: str | None = None + auth_required_claims: str | None = None + auth_read_scope: str | None = None + auth_write_scope: str | None = None + auth_authorization_claim: str | None = None + auth_base_url: str | None = None + @classmethod def from_env( cls, @@ -39,3 +55,34 @@ def from_env( # `BaseSettings` fills any missing fields from the configured env prefix. return cls(**cast(dict[str, Any], overrides)) + + def auth_overrides(self) -> dict[str, Any]: + """Return the non-``None`` ``auth_*`` fields as an `MCPAuthConfig` mapping. + + Comma-separated `auth_required_scopes` is split into a list. Returns an + empty dict when no auth env vars are set. + """ + mapping = { + "type": self.auth_type, + "jwks_uri": self.auth_jwks_uri, + "public_key": self.auth_public_key, + "issuer": self.auth_issuer, + "audience": self.auth_audience, + "algorithm": self.auth_algorithm, + "read_scope": self.auth_read_scope, + "write_scope": self.auth_write_scope, + "authorization_claim": self.auth_authorization_claim, + "base_url": self.auth_base_url, + } + overrides: dict[str, Any] = { + key: value for key, value in mapping.items() if value is not None + } + for env_value, field in ( + (self.auth_required_scopes, "required_scopes"), + (self.auth_required_claims, "required_claims"), + ): + if env_value is not None: + overrides[field] = [ + item.strip() for item in env_value.split(",") if item.strip() + ] + return overrides diff --git a/redisvl/mcp/tools/search.py b/redisvl/mcp/tools/search.py index 00ef02d6..89cfa00f 100644 --- a/redisvl/mcp/tools/search.py +++ b/redisvl/mcp/tools/search.py @@ -2,6 +2,7 @@ import inspect from typing import Any +from redisvl.mcp.auth import ensure_tool_scope from redisvl.mcp.config import reserved_score_metadata_field_names from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception from redisvl.mcp.filters import parse_filter @@ -481,6 +482,8 @@ async def search_records_tool( return_fields: list[str] | None = None, ): """FastMCP wrapper for the `search-records` tool.""" + read_scope = getattr(getattr(server, "auth_config", None), "read_scope", None) + ensure_tool_scope(server, read_scope) return await search_records( server, query=query, diff --git a/redisvl/mcp/tools/upsert.py b/redisvl/mcp/tools/upsert.py index 293a1119..dd5721e8 100644 --- a/redisvl/mcp/tools/upsert.py +++ b/redisvl/mcp/tools/upsert.py @@ -3,6 +3,7 @@ from copy import deepcopy from typing import Any +from redisvl.mcp.auth import ensure_tool_scope from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception from redisvl.redis.utils import array_to_buffer from redisvl.schema.schema import StorageType @@ -355,6 +356,8 @@ async def upsert_records_tool( skip_embedding_if_present: bool | None = None, ): """FastMCP wrapper for the `upsert-records` tool.""" + write_scope = getattr(getattr(server, "auth_config", None), "write_scope", None) + ensure_tool_scope(server, write_scope) return await upsert_records( server, records=records, diff --git a/tests/integration/test_mcp/test_auth.py b/tests/integration/test_mcp/test_auth.py new file mode 100644 index 00000000..895f2f6e --- /dev/null +++ b/tests/integration/test_mcp/test_auth.py @@ -0,0 +1,147 @@ +"""Integration tests for MCP JWT authentication. + +These mint real RS256 tokens with FastMCP's ``RSAKeyPair`` and verify them +through a ``JWTVerifier`` built from ``MCPAuthConfig``. No network JWKS endpoint +is required: the verifier is configured with the key pair's static public key. + +The token fixture is modeled on a real enterprise OIDC access token, with all +organization-specific values replaced by dummies (subject ``nitin``, org +``redis``). The ``tid``/``oid``/``upn`` claims are carried but not acted on by +the server in phase 1; they would drive a gateway binding table. +""" + +import time + +import pytest + +fastmcp = pytest.importorskip( + "fastmcp", reason="fastmcp not installed (install redisvl[mcp])" +) +from authlib.jose import jwt as jose_jwt +from fastmcp.server.auth.providers.jwt import RSAKeyPair + +from redisvl.mcp.auth import build_auth_provider, token_has_scope +from redisvl.mcp.config import MCPAuthConfig + + +@pytest.fixture(scope="session", autouse=True) +def redis_container(): + # JWT validation is pure crypto; shadow the repo-wide Docker/Redis fixture so + # these tests do not require a running Redis. + yield None + + +ISSUER = "https://auth.redis.example/abc123/v2.0" +AUDIENCE = "api://redisvl-mcp" +READ_SCOPE = "kb.search.read" +WRITE_SCOPE = "kb.search.write" + +# Sanitized claims modeled on a real OIDC access token. +BASE_CLAIMS = { + "oid": "00000000-nitin-0000-000000000000", + "upn": "nitin@redis.example", + "tid": "11111111-2222-3333-4444-555555555555", +} + + +@pytest.fixture(scope="module") +def key() -> RSAKeyPair: + return RSAKeyPair.generate() + + +@pytest.fixture() +def verifier(key): + return build_auth_provider( + MCPAuthConfig( + type="jwt", + public_key=key.public_key, + issuer=ISSUER, + audience=AUDIENCE, + required_scopes=[READ_SCOPE], + read_scope=READ_SCOPE, + write_scope=WRITE_SCOPE, + ) + ) + + +def _mint(key, *, scopes, audience=AUDIENCE, issuer=ISSUER, expires_in=3600): + return key.create_token( + subject="nitin", + issuer=issuer, + audience=audience, + scopes=scopes, + expires_in_seconds=expires_in, + additional_claims=BASE_CLAIMS, + ) + + +async def test_valid_read_token_is_accepted(verifier, key): + token = _mint(key, scopes=[READ_SCOPE]) + access = await verifier.verify_token(token) + assert access is not None + assert READ_SCOPE in access.scopes + assert token_has_scope(access, READ_SCOPE) + assert not token_has_scope(access, WRITE_SCOPE) + + +async def test_write_token_carries_write_scope(verifier, key): + token = _mint(key, scopes=[READ_SCOPE, WRITE_SCOPE]) + access = await verifier.verify_token(token) + assert access is not None + assert token_has_scope(access, WRITE_SCOPE) + + +async def test_wrong_audience_is_rejected(verifier, key): + token = _mint(key, scopes=[READ_SCOPE], audience="api://some-other-service") + assert await verifier.verify_token(token) is None + + +async def test_wrong_issuer_is_rejected(verifier, key): + token = _mint(key, scopes=[READ_SCOPE], issuer="https://evil.example/v2.0") + assert await verifier.verify_token(token) is None + + +async def test_expired_token_is_rejected(verifier, key): + token = _mint(key, scopes=[READ_SCOPE], expires_in=-10) + assert await verifier.verify_token(token) is None + + +async def test_missing_required_scope_is_rejected(verifier, key): + # Required connect scope is READ_SCOPE; a token without it must fail. + token = _mint(key, scopes=["kb.unrelated"]) + assert await verifier.verify_token(token) is None + + +async def test_garbage_token_is_rejected(verifier): + assert await verifier.verify_token("not-a-jwt") is None + + +def _mint_raw(key, claims): + """Sign a token with arbitrary claims (used to omit exp/iat).""" + token = jose_jwt.encode( + {"alg": "RS256"}, claims, key.private_key.get_secret_value() + ) + return token.decode() if isinstance(token, bytes) else token + + +async def test_token_without_exp_is_rejected(verifier, key): + # A token with no exp would never expire; it must be rejected. + claims = { + "iss": ISSUER, + "aud": AUDIENCE, + "sub": "nitin", + "scope": READ_SCOPE, + "iat": int(time.time()), + } + assert await verifier.verify_token(_mint_raw(key, claims)) is None + + +async def test_token_without_iat_is_rejected(verifier, key): + claims = { + "iss": ISSUER, + "aud": AUDIENCE, + "sub": "nitin", + "scope": READ_SCOPE, + "exp": int(time.time()) + 3600, + } + assert await verifier.verify_token(_mint_raw(key, claims)) is None diff --git a/tests/integration/test_mcp/test_transport_auth.py b/tests/integration/test_mcp/test_transport_auth.py new file mode 100644 index 00000000..a31d84f7 --- /dev/null +++ b/tests/integration/test_mcp/test_transport_auth.py @@ -0,0 +1,322 @@ +"""Integration tests for MCP JWT auth over the streamable-http transport. + +These start the server on a real port and connect a FastMCP client with and +without a bearer token, verifying that unauthenticated and mis-scoped requests +are rejected while a valid scoped token can list tools and search. Tokens are +minted with FastMCP's ``RSAKeyPair`` and validated against its static public +key, so no network JWKS endpoint is needed. +""" + +import asyncio +import socket +from pathlib import Path + +import pytest +import yaml + +fastmcp = pytest.importorskip( + "fastmcp", reason="fastmcp not installed (install redisvl[mcp])" +) +import time + +from authlib.jose import jwt as jose_jwt +from fastmcp import Client +from fastmcp.client.auth import BearerAuth +from fastmcp.exceptions import ToolError +from fastmcp.server.auth.providers.jwt import RSAKeyPair + +from redisvl.index import AsyncSearchIndex +from redisvl.mcp.server import RedisVLMCPServer +from redisvl.mcp.settings import MCPSettings +from redisvl.schema import IndexSchema + +ISSUER = "https://auth.redis.example/abc123/v2.0" +AUDIENCE = "api://redisvl-mcp" +READ_SCOPE = "kb.search.read" +WRITE_SCOPE = "kb.search.write" + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +async def _assert_unauthorized(url, auth=None) -> None: + """Assert a request is rejected at the transport with HTTP 401.""" + with pytest.raises(Exception) as exc_info: + async with Client(url, auth=auth) as client: + await client.list_tools() + assert "401" in str(exc_info.value), f"expected 401, got {exc_info.value!r}" + + +async def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None: + deadline = asyncio.get_running_loop().time() + timeout + while True: + try: + _, writer = await asyncio.open_connection(host, port) + writer.close() + await writer.wait_closed() + return + except OSError: + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError(f"server on {host}:{port} not ready") + await asyncio.sleep(0.05) + + +@pytest.fixture +async def auth_index(async_client, worker_id): + """Create a small fulltext-searchable index for auth transport tests.""" + schema = IndexSchema.from_dict( + { + "index": { + "name": f"mcp-auth-{worker_id}", + "prefix": f"mcp-auth:{worker_id}", + "storage_type": "hash", + }, + "fields": [{"name": "content", "type": "text"}], + } + ) + index = AsyncSearchIndex(schema=schema, redis_client=async_client) + await index.create(overwrite=True, drop=True) + await index.load( + [{"id": f"adoc:{worker_id}:1", "content": "transport test document science"}] + ) + yield index + await index.delete(drop=True) + + +@pytest.fixture +def auth_config_path(tmp_path: Path, redis_url: str): + """Build a JWT-authenticated MCP config for a given index and public key.""" + + def factory(redis_name: str, public_key: str) -> str: + config = { + "server": { + "redis_url": redis_url, + "auth": { + "type": "jwt", + "public_key": public_key, + "issuer": ISSUER, + "audience": AUDIENCE, + "required_scopes": [READ_SCOPE], + "read_scope": READ_SCOPE, + "write_scope": WRITE_SCOPE, + }, + }, + "indexes": { + "knowledge": { + "redis_name": redis_name, + "search": {"type": "fulltext"}, + "runtime": {"text_field_name": "content"}, + } + }, + } + config_path = tmp_path / f"{redis_name}.yaml" + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + return str(config_path) + + return factory + + +async def test_http_transport_enforces_jwt_auth(auth_index, auth_config_path): + key = RSAKeyPair.generate() + server = RedisVLMCPServer( + MCPSettings( + config=auth_config_path(auth_index.schema.index.name, key.public_key) + ) + ) + assert server._auth_enabled is True + + port = _find_free_port() + url = f"http://127.0.0.1:{port}/mcp" + server_task = asyncio.create_task( + server.run_async(transport="streamable-http", host="127.0.0.1", port=port) + ) + try: + await _wait_for_port("127.0.0.1", port) + + # No token is rejected. + await _assert_unauthorized(url) + + # Garbage token is rejected. + await _assert_unauthorized(url, BearerAuth("garbage")) + + # Wrong audience is rejected. + bad_aud = key.create_token( + subject="nitin", + issuer=ISSUER, + audience="api://some-other-service", + scopes=[READ_SCOPE], + ) + await _assert_unauthorized(url, BearerAuth(bad_aud)) + + # Valid scoped token is accepted and can search. + good = key.create_token( + subject="nitin", + issuer=ISSUER, + audience=AUDIENCE, + scopes=[READ_SCOPE], + ) + async with Client(url, auth=BearerAuth(good)) as client: + tool_names = [t.name for t in await client.list_tools()] + assert "search-records" in tool_names + result = await client.call_tool( + "search-records", {"query": "science", "limit": 1} + ) + assert result is not None + assert len(result.content) > 0 + finally: + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + + +async def test_http_transport_gates_write_by_scope(auth_index, auth_config_path): + key = RSAKeyPair.generate() + server = RedisVLMCPServer( + MCPSettings( + config=auth_config_path(auth_index.schema.index.name, key.public_key) + ) + ) + port = _find_free_port() + url = f"http://127.0.0.1:{port}/mcp" + server_task = asyncio.create_task( + server.run_async(transport="streamable-http", host="127.0.0.1", port=port) + ) + try: + await _wait_for_port("127.0.0.1", port) + + # A read-only token can search but cannot upsert. + read_token = key.create_token( + subject="nitin", issuer=ISSUER, audience=AUDIENCE, scopes=[READ_SCOPE] + ) + async with Client(url, auth=BearerAuth(read_token)) as client: + await client.call_tool("search-records", {"query": "science", "limit": 1}) + with pytest.raises(ToolError): + await client.call_tool( + "upsert-records", {"records": [{"content": "blocked"}]} + ) + + # A read+write token can upsert. + rw_token = key.create_token( + subject="nitin", + issuer=ISSUER, + audience=AUDIENCE, + scopes=[READ_SCOPE, WRITE_SCOPE], + ) + async with Client(url, auth=BearerAuth(rw_token)) as client: + result = await client.call_tool( + "upsert-records", {"records": [{"content": "written by rw token"}]} + ) + assert result is not None + finally: + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + + +async def test_http_transport_gates_by_roles_claim( + auth_index, tmp_path: Path, redis_url: str +): + """Authorization carried in a ``roles`` claim (as Azure AD / Entra does).""" + key = RSAKeyPair.generate() + config = { + "server": { + "redis_url": redis_url, + "auth": { + "type": "jwt", + "public_key": key.public_key, + "issuer": ISSUER, + "audience": AUDIENCE, + "required_scopes": ["kb.read"], # connect gate on scp + "read_scope": READ_SCOPE, + "write_scope": WRITE_SCOPE, + "authorization_claim": "roles", + }, + }, + "indexes": { + "knowledge": { + "redis_name": auth_index.schema.index.name, + "search": {"type": "fulltext"}, + "runtime": {"text_field_name": "content"}, + } + }, + } + config_path = tmp_path / "roles.yaml" + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + + server = RedisVLMCPServer(MCPSettings(config=str(config_path))) + port = _find_free_port() + url = f"http://127.0.0.1:{port}/mcp" + server_task = asyncio.create_task( + server.run_async(transport="streamable-http", host="127.0.0.1", port=port) + ) + try: + await _wait_for_port("127.0.0.1", port) + + # scp grants connect; roles grants only read, so search works, upsert blocked. + token = key.create_token( + subject="nitin", + issuer=ISSUER, + audience=AUDIENCE, + scopes=["kb.read"], + additional_claims={"roles": [READ_SCOPE], "tid": "tenant-guid"}, + ) + async with Client(url, auth=BearerAuth(token)) as client: + await client.call_tool("search-records", {"query": "science", "limit": 1}) + with pytest.raises(ToolError): + await client.call_tool( + "upsert-records", {"records": [{"content": "blocked"}]} + ) + finally: + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + + +async def test_http_transport_rejects_token_without_exp(auth_index, auth_config_path): + """A non-expiring (no exp) token must be rejected over the live HTTP path.""" + key = RSAKeyPair.generate() + server = RedisVLMCPServer( + MCPSettings( + config=auth_config_path(auth_index.schema.index.name, key.public_key) + ) + ) + port = _find_free_port() + url = f"http://127.0.0.1:{port}/mcp" + server_task = asyncio.create_task( + server.run_async(transport="streamable-http", host="127.0.0.1", port=port) + ) + try: + await _wait_for_port("127.0.0.1", port) + + # Properly signed and scoped, but no exp claim. authlib signs it; the + # server must still reject it because exp is required. + no_exp = jose_jwt.encode( + {"alg": "RS256"}, + { + "iss": ISSUER, + "aud": AUDIENCE, + "sub": "nitin", + "scope": READ_SCOPE, + "iat": int(time.time()), + }, + key.private_key.get_secret_value(), + ) + if isinstance(no_exp, bytes): + no_exp = no_exp.decode() + + await _assert_unauthorized(url, BearerAuth(no_exp)) + finally: + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass diff --git a/tests/unit/test_cli_mcp.py b/tests/unit/test_cli_mcp.py index f167cfbd..1c889181 100644 --- a/tests/unit/test_cli_mcp.py +++ b/tests/unit/test_cli_mcp.py @@ -382,6 +382,7 @@ def test_mcp_command_passes_streamable_http_transport(monkeypatch): "0.0.0.0", "--port", "9000", + "--allow-unauthenticated", ], ) @@ -487,6 +488,7 @@ def test_mcp_command_fallback_run_path_passes_http_transport_kwargs(monkeypatch) "0.0.0.0", "--port", "7777", + "--allow-unauthenticated", ], ) diff --git a/tests/unit/test_mcp/test_auth_config.py b/tests/unit/test_mcp/test_auth_config.py new file mode 100644 index 00000000..f1afaa24 --- /dev/null +++ b/tests/unit/test_mcp/test_auth_config.py @@ -0,0 +1,105 @@ +"""Unit tests for MCP auth config validation (`MCPAuthConfig`). + +These cover the configuration contract only; provider construction and token +verification are tested separately. +""" + +import pytest +from pydantic import ValidationError + +from redisvl.mcp.config import MCPAuthConfig + + +def test_auth_type_defaults_to_none(): + cfg = MCPAuthConfig() + assert cfg.type == "none" + + +def test_jwt_with_jwks_uri_is_valid(): + cfg = MCPAuthConfig( + type="jwt", + jwks_uri="https://auth.redis.example/abc123/discovery/v2.0/keys", + issuer="https://auth.redis.example/abc123/v2.0", + audience="api://redisvl-mcp", + required_scopes=["kb.read"], + read_scope="kb.search.read", + write_scope="kb.search.write", + ) + assert cfg.type == "jwt" + assert cfg.audience == "api://redisvl-mcp" + assert cfg.read_scope == "kb.search.read" + + +def test_jwt_with_static_public_key_is_valid(): + cfg = MCPAuthConfig( + type="jwt", + public_key="-----BEGIN PUBLIC KEY-----\nMII...\n-----END PUBLIC KEY-----", + issuer="https://auth.redis.example/abc123/v2.0", + audience="api://redisvl-mcp", + ) + assert cfg.public_key is not None + assert cfg.jwks_uri is None + + +def test_jwt_rejects_both_jwks_uri_and_public_key(): + with pytest.raises(ValidationError, match="exactly one"): + MCPAuthConfig( + type="jwt", + jwks_uri="https://auth.redis.example/keys", + public_key="-----BEGIN PUBLIC KEY-----\nMII...\n-----END PUBLIC KEY-----", + audience="api://redisvl-mcp", + ) + + +def test_jwt_rejects_neither_jwks_uri_nor_public_key(): + with pytest.raises(ValidationError, match="exactly one"): + MCPAuthConfig( + type="jwt", + issuer="https://auth.redis.example/abc123/v2.0", + audience="api://redisvl-mcp", + ) + + +def test_jwt_requires_audience(): + with pytest.raises(ValidationError, match="audience"): + MCPAuthConfig( + type="jwt", + jwks_uri="https://auth.redis.example/keys", + issuer="https://auth.redis.example/abc123/v2.0", + ) + + +def test_jwt_requires_issuer(): + with pytest.raises(ValidationError, match="issuer"): + MCPAuthConfig( + type="jwt", + jwks_uri="https://auth.redis.example/keys", + audience="api://redisvl-mcp", + ) + + +def test_none_type_with_jwt_fields_is_rejected(): + # A JWT-looking block that forgot type: jwt must fail loudly, not silently + # run unauthenticated. + with pytest.raises(ValidationError, match="type is not 'jwt'"): + MCPAuthConfig( + jwks_uri="https://auth.redis.example/keys", + audience="api://redisvl-mcp", + ) + + +def test_unknown_auth_type_is_rejected(): + with pytest.raises(ValidationError): + MCPAuthConfig(type="kerberos") + + +def test_none_type_ignores_jwt_fields(): + # A `none` config should never require jwt-only fields. + cfg = MCPAuthConfig(type="none") + assert cfg.audience is None + assert cfg.jwks_uri is None + + +def test_required_claims_default_to_exp_and_iat(): + # A token without exp would never expire, so exp (and iat) are required. + assert MCPAuthConfig().required_claims == ["exp", "iat"] diff --git a/tests/unit/test_mcp/test_auth_provider.py b/tests/unit/test_mcp/test_auth_provider.py new file mode 100644 index 00000000..fd7a19a9 --- /dev/null +++ b/tests/unit/test_mcp/test_auth_provider.py @@ -0,0 +1,57 @@ +"""Unit tests for building a FastMCP auth provider from `MCPAuthConfig`.""" + +import pytest + +from redisvl.mcp.config import MCPAuthConfig + +fastmcp = pytest.importorskip( + "fastmcp", reason="fastmcp not installed (install redisvl[mcp])" +) +from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair + +from redisvl.mcp.auth import ( + build_auth_provider, + missing_required_claims, + token_has_scope, +) + + +def test_build_returns_none_for_none_type(): + assert build_auth_provider(MCPAuthConfig(type="none")) is None + + +def test_build_returns_none_for_missing_config(): + assert build_auth_provider(None) is None + + +def test_build_returns_jwt_verifier(): + key = RSAKeyPair.generate() + provider = build_auth_provider( + MCPAuthConfig( + type="jwt", + public_key=key.public_key, + issuer="https://auth.redis.example/abc123/v2.0", + audience="api://redisvl-mcp", + required_scopes=["kb.read"], + ) + ) + assert isinstance(provider, JWTVerifier) + + +def test_token_has_scope_helper(): + class _AccessToken: + def __init__(self, scopes): + self.scopes = scopes + + assert token_has_scope(_AccessToken(["kb.search.read"]), "kb.search.read") + assert not token_has_scope(_AccessToken(["kb.search.read"]), "kb.search.write") + # No required scope configured means the gate is open. + assert token_has_scope(_AccessToken([]), None) + + +def test_missing_required_claims(): + assert missing_required_claims({"exp": 1, "iat": 1}, ["exp", "iat"]) == [] + assert missing_required_claims({"iat": 1}, ["exp", "iat"]) == ["exp"] + assert missing_required_claims({}, ["exp"]) == ["exp"] + assert missing_required_claims(None, ["exp"]) == ["exp"] + assert missing_required_claims({"exp": 1}, []) == [] diff --git a/tests/unit/test_mcp/test_auth_resolution.py b/tests/unit/test_mcp/test_auth_resolution.py new file mode 100644 index 00000000..e994c9a0 --- /dev/null +++ b/tests/unit/test_mcp/test_auth_resolution.py @@ -0,0 +1,93 @@ +"""Unit tests for resolving auth config from env (`MCPSettings`) and YAML. + +Env vars take precedence over the YAML `server.auth` block. +""" + +from pathlib import Path + +import yaml + +from redisvl.mcp.auth import resolve_auth_config +from redisvl.mcp.settings import MCPSettings + + +def _write_config(tmp_path: Path, auth_block: dict | None) -> str: + config = { + "server": {"redis_url": "redis://localhost:6379"}, + "indexes": { + "knowledge": { + "redis_name": "docs-index", + "search": {"type": "fulltext"}, + "runtime": {"text_field_name": "content"}, + } + }, + } + if auth_block is not None: + config["server"]["auth"] = auth_block + path = tmp_path / "mcp.yaml" + path.write_text(yaml.safe_dump(config)) + return str(path) + + +def test_resolves_none_when_unset(tmp_path, monkeypatch): + for var in ("REDISVL_MCP_AUTH_TYPE", "REDISVL_MCP_AUTH_JWKS_URI"): + monkeypatch.delenv(var, raising=False) + path = _write_config(tmp_path, auth_block=None) + settings = MCPSettings.from_env(config=path) + assert resolve_auth_config(settings, path) is None + + +def test_resolves_jwt_from_yaml(tmp_path, monkeypatch): + for var in ("REDISVL_MCP_AUTH_TYPE", "REDISVL_MCP_AUTH_AUDIENCE"): + monkeypatch.delenv(var, raising=False) + path = _write_config( + tmp_path, + auth_block={ + "type": "jwt", + "jwks_uri": "https://auth.redis.example/keys", + "issuer": "https://auth.redis.example/abc123/v2.0", + "audience": "api://redisvl-mcp", + "read_scope": "kb.search.read", + }, + ) + settings = MCPSettings.from_env(config=path) + cfg = resolve_auth_config(settings, path) + assert cfg is not None + assert cfg.type == "jwt" + assert cfg.audience == "api://redisvl-mcp" + assert cfg.read_scope == "kb.search.read" + + +def test_env_overrides_yaml(tmp_path, monkeypatch): + path = _write_config( + tmp_path, + auth_block={ + "type": "jwt", + "jwks_uri": "https://auth.redis.example/keys", + "issuer": "https://auth.redis.example/abc123/v2.0", + "audience": "api://from-yaml", + }, + ) + monkeypatch.setenv("REDISVL_MCP_AUTH_TYPE", "jwt") + monkeypatch.setenv("REDISVL_MCP_AUTH_JWKS_URI", "https://auth.redis.example/keys") + monkeypatch.setenv("REDISVL_MCP_AUTH_AUDIENCE", "api://from-env") + settings = MCPSettings.from_env(config=path) + cfg = resolve_auth_config(settings, path) + assert cfg is not None + assert cfg.audience == "api://from-env" + + +def test_env_type_none_disables_yaml_auth(tmp_path, monkeypatch): + # Explicit env type=none must turn auth off even when YAML defines a jwt block. + path = _write_config( + tmp_path, + auth_block={ + "type": "jwt", + "jwks_uri": "https://auth.redis.example/keys", + "issuer": "https://auth.redis.example/abc123/v2.0", + "audience": "api://redisvl-mcp", + }, + ) + monkeypatch.setenv("REDISVL_MCP_AUTH_TYPE", "none") + settings = MCPSettings.from_env(config=path) + assert resolve_auth_config(settings, path) is None diff --git a/tests/unit/test_mcp/test_auth_scope.py b/tests/unit/test_mcp/test_auth_scope.py new file mode 100644 index 00000000..a7186f94 --- /dev/null +++ b/tests/unit/test_mcp/test_auth_scope.py @@ -0,0 +1,120 @@ +"""Unit tests for read/write scope gating and the configurable auth claim.""" + +import pytest + +# These tests monkeypatch fastmcp.server.dependencies.get_access_token, which +# imports fastmcp; skip the module when the optional extra is absent. +pytest.importorskip("fastmcp", reason="fastmcp not installed (install redisvl[mcp])") + +from redisvl.mcp.auth import authorization_values, ensure_tool_scope, token_has_scope +from redisvl.mcp.config import MCPAuthConfig +from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError + + +class _AccessToken: + def __init__(self, scopes=None, claims=None): + self.scopes = scopes or [] + self.claims = claims or {} + + +class _Cfg: + def __init__(self, authorization_claim="scp"): + self.authorization_claim = authorization_claim + + +class _Server: + def __init__(self, enabled=True, authorization_claim="scp"): + self._auth_enabled = enabled + self.auth_config = _Cfg(authorization_claim) if enabled else None + + +# --- claim selection ------------------------------------------------------- + + +def test_authorization_values_reads_scopes_for_scp(): + tok = _AccessToken(scopes=["kb.read"], claims={"roles": ["kb.search.read"]}) + assert authorization_values(tok, "scp") == ["kb.read"] + + +def test_authorization_values_reads_named_claim_list(): + tok = _AccessToken(scopes=["kb.read"], claims={"roles": ["kb.search.read"]}) + assert authorization_values(tok, "roles") == ["kb.search.read"] + + +def test_authorization_values_splits_space_delimited_claim(): + tok = _AccessToken(claims={"roles": "kb.search.read kb.search.write"}) + assert authorization_values(tok, "roles") == ["kb.search.read", "kb.search.write"] + + +def test_authorization_values_missing_claim_is_empty(): + assert authorization_values(_AccessToken(), "roles") == [] + + +# --- token_has_scope ------------------------------------------------------- + + +def test_token_has_scope_uses_named_claim(): + tok = _AccessToken(scopes=["kb.read"], claims={"roles": ["kb.search.read"]}) + assert token_has_scope(tok, "kb.search.read", "roles") + assert not token_has_scope(tok, "kb.search.write", "roles") + # The default scp claim does not see the roles claim. + assert not token_has_scope(tok, "kb.search.read") + + +# --- config ---------------------------------------------------------------- + + +def test_authorization_claim_defaults_to_scp(): + assert MCPAuthConfig().authorization_claim == "scp" + + +def test_authorization_claim_can_be_roles(): + cfg = MCPAuthConfig( + type="jwt", + public_key="-----BEGIN PUBLIC KEY-----\nMII...\n-----END PUBLIC KEY-----", + issuer="https://auth.redis.example/abc123/v2.0", + audience="api://redisvl-mcp", + authorization_claim="roles", + ) + assert cfg.authorization_claim == "roles" + + +# --- ensure_tool_scope ----------------------------------------------------- + + +def test_ensure_tool_scope_noop_when_auth_disabled(): + # No token lookup, no raise. + ensure_tool_scope(_Server(enabled=False), "kb.search.write") + + +def test_ensure_tool_scope_noop_when_scope_not_configured(): + ensure_tool_scope(_Server(), None) + + +def test_ensure_tool_scope_allows_when_scope_present(monkeypatch): + tok = _AccessToken(claims={"roles": ["kb.search.write"]}) + monkeypatch.setattr( + "fastmcp.server.dependencies.get_access_token", lambda: tok, raising=False + ) + ensure_tool_scope(_Server(authorization_claim="roles"), "kb.search.write") + + +def test_ensure_tool_scope_forbids_when_scope_missing(monkeypatch): + tok = _AccessToken(claims={"roles": ["kb.search.read"]}) + monkeypatch.setattr( + "fastmcp.server.dependencies.get_access_token", lambda: tok, raising=False + ) + with pytest.raises(RedisVLMCPError) as exc: + ensure_tool_scope(_Server(authorization_claim="roles"), "kb.search.write") + assert exc.value.code == MCPErrorCode.FORBIDDEN + assert exc.value.retryable is False + + +def test_ensure_tool_scope_noop_when_no_token(monkeypatch): + # No token means no authenticated request context (for example stdio). + # Authenticated HTTP transports reject tokenless requests before the tool + # runs, so the gate must not fire here. + monkeypatch.setattr( + "fastmcp.server.dependencies.get_access_token", lambda: None, raising=False + ) + ensure_tool_scope(_Server(), "kb.search.read") diff --git a/tests/unit/test_mcp/test_cli_auth.py b/tests/unit/test_mcp/test_cli_auth.py new file mode 100644 index 00000000..a8123d33 --- /dev/null +++ b/tests/unit/test_mcp/test_cli_auth.py @@ -0,0 +1,30 @@ +"""Unit tests for the CLI unauthenticated-HTTP-bind guard.""" + +import pytest + +from redisvl.cli.mcp import MCP + + +def test_stdio_never_warns(): + assert MCP._check_http_auth("stdio", "127.0.0.1", False, False) is None + + +def test_http_with_auth_is_silent(): + assert MCP._check_http_auth("streamable-http", "0.0.0.0", True, False) is None + + +def test_http_loopback_without_auth_warns(): + msg = MCP._check_http_auth("streamable-http", "127.0.0.1", False, False) + assert msg is not None + assert "without" in msg.lower() + + +def test_http_non_loopback_without_auth_fails_closed(): + with pytest.raises(RuntimeError, match="non-loopback"): + MCP._check_http_auth("streamable-http", "0.0.0.0", False, False) + + +def test_http_non_loopback_allowed_with_flag_warns(): + msg = MCP._check_http_auth("streamable-http", "0.0.0.0", False, True) + assert msg is not None + assert "WARNING" in msg diff --git a/tests/unit/test_mcp/test_server_auth.py b/tests/unit/test_mcp/test_server_auth.py new file mode 100644 index 00000000..ec94588d --- /dev/null +++ b/tests/unit/test_mcp/test_server_auth.py @@ -0,0 +1,110 @@ +"""Unit tests for wiring auth into the MCP server constructor.""" + +from pathlib import Path + +import pytest +import yaml + +fastmcp = pytest.importorskip( + "fastmcp", reason="fastmcp not installed (install redisvl[mcp])" +) +from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair + +from redisvl.mcp.server import RedisVLMCPServer +from redisvl.mcp.settings import MCPSettings + + +def _write_config(tmp_path: Path, auth_block: dict | None) -> str: + config = { + "server": {"redis_url": "redis://localhost:6379"}, + "indexes": { + "knowledge": { + "redis_name": "docs-index", + "search": {"type": "fulltext"}, + "runtime": {"text_field_name": "content"}, + } + }, + } + if auth_block is not None: + config["server"]["auth"] = auth_block + path = tmp_path / "mcp.yaml" + path.write_text(yaml.safe_dump(config)) + return str(path) + + +def test_server_without_auth_is_unauthenticated(tmp_path, monkeypatch): + for var in ("REDISVL_MCP_AUTH_TYPE", "REDISVL_MCP_AUTH_AUDIENCE"): + monkeypatch.delenv(var, raising=False) + path = _write_config(tmp_path, auth_block=None) + server = RedisVLMCPServer(MCPSettings.from_env(config=path)) + assert server._auth_enabled is False + assert server.auth is None + + +def test_server_with_jwt_auth_attaches_verifier(tmp_path, monkeypatch): + for var in ("REDISVL_MCP_AUTH_TYPE", "REDISVL_MCP_AUTH_AUDIENCE"): + monkeypatch.delenv(var, raising=False) + key = RSAKeyPair.generate() + path = _write_config( + tmp_path, + auth_block={ + "type": "jwt", + "public_key": key.public_key, + "issuer": "https://auth.redis.example/abc123/v2.0", + "audience": "api://redisvl-mcp", + "required_scopes": ["kb.read"], + "read_scope": "kb.search.read", + "write_scope": "kb.search.write", + }, + ) + server = RedisVLMCPServer(MCPSettings.from_env(config=path)) + assert server._auth_enabled is True + assert isinstance(server.auth, JWTVerifier) + assert server.auth_config is not None + assert server.auth_config.read_scope == "kb.search.read" + + +def test_stale_auth_after_construction_fails_closed(tmp_path, monkeypatch): + for var in ("REDISVL_MCP_AUTH_TYPE", "REDISVL_MCP_AUTH_AUDIENCE"): + monkeypatch.delenv(var, raising=False) + # Construct with no auth, so the provider is not wired. + path = _write_config(tmp_path, auth_block=None) + server = RedisVLMCPServer(MCPSettings.from_env(config=path)) + assert server._auth_enabled is False + + # The config now gains a JWT auth block (as if it became readable only after + # construction). Startup must refuse rather than serve unauthenticated. + key = RSAKeyPair.generate() + Path(path).write_text( + yaml.safe_dump( + { + "server": { + "redis_url": "redis://localhost:6379", + "auth": { + "type": "jwt", + "public_key": key.public_key, + "issuer": "https://auth.redis.example/abc123/v2.0", + "audience": "api://redisvl-mcp", + }, + }, + "indexes": { + "knowledge": { + "redis_name": "docs-index", + "search": {"type": "fulltext"}, + "runtime": {"text_field_name": "content"}, + } + }, + } + ) + ) + with pytest.raises(RuntimeError, match="stale"): + server._verify_auth_not_stale() + + +def test_consistent_auth_does_not_fail(tmp_path, monkeypatch): + for var in ("REDISVL_MCP_AUTH_TYPE", "REDISVL_MCP_AUTH_AUDIENCE"): + monkeypatch.delenv(var, raising=False) + path = _write_config(tmp_path, auth_block=None) + server = RedisVLMCPServer(MCPSettings.from_env(config=path)) + # Unchanged config: construction and startup agree, so no error. + server._verify_auth_not_stale() diff --git a/uv.lock b/uv.lock index f0dee76b..3e6b11aa 100644 --- a/uv.lock +++ b/uv.lock @@ -4869,7 +4869,7 @@ wheels = [ [[package]] name = "redisvl" -version = "0.19.0" +version = "0.20.0" source = { editable = "." } dependencies = [ { name = "jsonpath-ng" }, @@ -4973,6 +4973,7 @@ docs = [ { name = "sphinx-copybutton" }, { name = "sphinx-design" }, { name = "sphinx-favicon" }, + { name = "sphinxcontrib-mermaid" }, ] [package.metadata] @@ -5045,6 +5046,7 @@ docs = [ { name = "sphinx-copybutton", specifier = ">=0.5.2,<0.6" }, { name = "sphinx-design", specifier = ">=0.5.0,<0.6" }, { name = "sphinx-favicon", specifier = ">=1.0.1,<2" }, + { name = "sphinxcontrib-mermaid", specifier = ">=0.9.2,<1" }, ] [[package]] @@ -5847,6 +5849,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] +[[package]] +name = "sphinxcontrib-mermaid" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/70/3d5664a7260a57b68f0b3ddcb29934b7888cbaf1577163e1db81bb9e42e2/sphinxcontrib-mermaid-0.9.2.tar.gz", hash = "sha256:252ef13dd23164b28f16d8b0205cf184b9d8e2b714a302274d9f59eb708e77af", size = 17909, upload-time = "2023-05-28T14:00:58.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/1e/e8b5c7536deba39eac1444c83d404374ed00c3a567445ba122249648a9fc/sphinxcontrib_mermaid-0.9.2-py3-none-any.whl", hash = "sha256:6795a72037ca55e65663d2a2c1a043d636dc3d30d418e56dd6087d1459d98a5d", size = 13453, upload-time = "2023-05-28T14:00:56.514Z" }, +] + [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0"