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)
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:
# 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.
fastmcp >= 2.0.0is the declared optional dependency (pyproject.toml, extramcp). Locally installed: fastmcp 3.1.0.- FastMCP's constructor accepts an
auth=provider (verified). JWTVerifierlives atfastmcp.server.auth.providers.jwt.JWTVerifierwith 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.
- Require authentication for the HTTP transports (
sse,streamable-http), opt-in and off by default. - Default mechanism: JWT bearer-token validation (
JWTVerifier) against an existing IdP's JWKS endpoint or a static public key. No OAuth authorization server. - Validate the audience (
aud) claim, not just signature and issuer, so a token minted for another service cannot be replayed (RFC 8707). - 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.
- Config-driven and import-safe: optional, never breaks installs without the
mcpextra; secrets stay out of the command line. - Make the insecure case loud: warn (or fail closed) when an HTTP transport is bound with no auth.
- 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-proxyoption is preferred later over one branch per vendor.
| 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.
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.
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-unauthenticatedis passed.
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.
| 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 |
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.
type: jwtrequires exactly one ofjwks_uriorpublic_key.type: jwtrequiresaudience(reject unbounded audience).type: jwtshould setissuer(warn if missing).- Unknown
type-> config error at load time. read_scope/write_scopeoptional; when set, gate the respective tool.
redisvl/mcp/config.py: addMCPAuthConfig(+ validators) and optionalauth: MCPAuthConfig | NoneonMCPServerConfig.redisvl/mcp/settings.py: addauth_*fields, plumb throughfrom_env.- New
redisvl/mcp/auth.py:resolve_auth_config(settings, config_path) -> MCPAuthConfig | None(env over YAML peek).build_auth_provider(auth_config) -> Any | NonereturningNonefornone, a configuredJWTVerifierforjwt; provider imports guarded for the optionalmcpextra.peek_yaml_auth(config_path) -> dict | Nonereads onlyserver.authwith env substitution.
redisvl/mcp/server.py: build the provider in__init__, passauth=; storeself._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 thefastmcpfloor that guaranteesJWTVerifier.
Tests are written first; implementation follows until green.
test_auth_config.py
none/unset -> resolves to no auth.jwtwithjwks_uri+issuer+audience-> valid config.jwtwith bothjwks_uriandpublic_key-> error.jwtwith neitherjwks_urinorpublic_key-> error.jwtmissingaudience-> error.- unknown
type-> error.
test_auth_provider.py
build_auth_provider(none)->None.build_auth_provider(jwt)-> instance ofJWTVerifiercarrying issuer, audience, required scopes.- import-safe behavior when
fastmcpis unavailable.
test_settings.py (extend)
REDISVL_MCP_AUTH_*env vars populate the new fields.- explicit
from_envargs override env.
test_auth_resolution.py
- env overrides YAML
server.auth. - YAML-only
server.authis honored when env is unset.
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-recordssucceeds. - valid write token ->
upsert-recordssucceeds. - read-only token ->
upsert-recordsrejected (insufficient scope). - wrong
aud-> rejected. - wrong
iss-> rejected. - expired token -> rejected.
- no/garbage token -> rejected (401).
- no-auth server -> any request succeeds (back-compat).
Modeled on a real enterprise OIDC access token, with all org-specific values
replaced by dummies (nitin, org redis):
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).
- Add an Authentication section to
docs/user_guide/15_mcp.ipynb: theserver.authblock,REDISVL_MCP_AUTH_*env vars, a workedJWTVerifierexample, scope gating, and the security note that HTTP transports must not be exposed unauthenticated. Notestdioneeds no auth.
- 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.
{ "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" }