Skip to content

feat(mcp): optional JWT authentication for HTTP transports#627

Closed
nkanu17 wants to merge 6 commits into
mainfrom
feat/mcp-auth
Closed

feat(mcp): optional JWT authentication for HTTP transports#627
nkanu17 wants to merge 6 commits into
mainfrom
feat/mcp-auth

Conversation

@nkanu17

@nkanu17 nkanu17 commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

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

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
Loading

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.

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]
Loading

Configuration

YAML (server.auth), with ${ENV} substitution for secrets:

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:

{
  "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.

nkanu17 and others added 6 commits June 9, 2026 19:54
Add an optional auth config model for the MCP server's HTTP transports.
Supports type none|jwt with JWKS or static public key, issuer, audience,
required/read/write scopes. Validators enforce exactly one JWT key source
and a required audience (RFC 8707) so tokens minted for other services
cannot be replayed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose auth_* env fields on MCPSettings and an auth_overrides() helper that
maps the non-None values to an MCPAuthConfig mapping, splitting comma-
separated required scopes. Env values take precedence over YAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add redisvl/mcp/auth.py: resolve_auth_config (env over YAML server.auth peek),
build_auth_provider (returns a configured JWTVerifier; fastmcp imported in-
function for the optional mcp extra), peek_yaml_auth, and the token_has_scope
gate helper. Covered by unit tests plus an integration test that mints real
RS256 tokens and asserts accept/reject on audience, issuer, expiry, and scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve auth at construction time and pass the provider to FastMCP, so HTTP
transports validate bearer tokens. Expose auth_config and _auth_enabled.
stdio is unaffected. Verified the server attaches a JWTVerifier for jwt and
none for unset config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Start the server over streamable-http and assert that no-token, garbage, and
wrong-audience requests are rejected while a valid scoped token can list tools
and search. Tokens are minted with RSAKeyPair and validated against its static
public key, so no network JWKS is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a how-to guide explaining JWT validation, audience binding, read/write
scope gating, the configurable authorization claim, and the gateway boundary
for per-tenant Redis ACL enforcement, with mermaid diagrams. Include the
design spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 9, 2026 23:56
@nkanu17 nkanu17 closed this Jun 9, 2026
@jit-ci

jit-ci Bot commented Jun 9, 2026

Copy link
Copy Markdown

🛡️ Jit Security Scan Results

CRITICAL HIGH MEDIUM

✅ No security findings were detected in this PR


Security scan by Jit

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds opt-in JWT authentication plumbing to the RedisVL MCP server so HTTP transports can validate bearer tokens based on config/env, alongside new docs/spec and unit/integration tests.

Changes:

  • Introduces MCPAuthConfig (YAML server.auth) + REDISVL_MCP_AUTH_* environment overrides, and resolves/builds a FastMCP auth= provider at server construction time.
  • Adds auth resolution/provider unit tests and JWT-focused integration tests (including streamable-http transport coverage).
  • Adds a draft auth spec (mcp-auth-spec.md) and a new user guide page documenting JWT authentication configuration.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
redisvl/mcp/settings.py Adds auth_* settings fields and an auth_overrides() helper for env-driven auth config.
redisvl/mcp/config.py Adds MCPAuthConfig and wires it into MCPServerConfig for YAML validation.
redisvl/mcp/auth.py New module to peek YAML auth, merge env/YAML, and build a FastMCP JWT auth provider.
redisvl/mcp/server.py Resolves auth at construction time and passes auth= into FastMCP base constructor.
tests/unit/test_mcp/test_auth_config.py Validates MCPAuthConfig contract/validation rules.
tests/unit/test_mcp/test_auth_provider.py Tests building a FastMCP auth provider + a scope helper.
tests/unit/test_mcp/test_auth_resolution.py Tests env-over-YAML auth config resolution logic.
tests/unit/test_mcp/test_server_auth.py Tests wiring auth into RedisVLMCPServer construction.
tests/integration/test_mcp/test_auth.py Crypto-level JWT verifier integration coverage (no Redis dependency).
tests/integration/test_mcp/test_transport_auth.py End-to-end HTTP transport auth checks using FastMCP client/server.
docs/user_guide/how_to_guides/mcp_authentication.md New documentation page describing JWT auth and (claimed) scope gating.
mcp-auth-spec.md Draft design/spec document for MCP auth and rollout plan.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread redisvl/mcp/auth.py
Comment on lines +56 to +63
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
Comment thread redisvl/mcp/server.py
Comment on lines +74 to +82
# 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, settings.config)
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)
Comment on lines +120 to +127
server:
auth:
type: jwt
# ...
authorization_claim: roles # default: scp
read_scope: kb.search.read
write_scope: kb.search.write
```
Comment on lines +32 to +33
- **Required scopes** to connect, and (optionally) a **read scope** to call
`search-records` and a **write scope** to call `upsert-records`.
Comment on lines +137 to +147
# Wrong audience is rejected.
bad_aud = key.create_token(
subject="nitin",
issuer=ISSUER,
audience="api://some-other-service",
scopes=[READ_SCOPE],
)
with pytest.raises(Exception):
async with Client(url, auth=BearerAuth(bad_aud)) as client:
await client.list_tools()

@nkanu17 nkanu17 changed the title Feat/mcp auth feat(mcp): optional JWT authentication for HTTP transports Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add optional authentication to the MCP server (JWT bearer validation for HTTP transports)

2 participants