Skip to content

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

Merged
nkanu17 merged 21 commits into
mainfrom
feat/mcp-auth
Jun 10, 2026
Merged

feat(mcp): optional JWT authentication for HTTP transports#628
nkanu17 merged 21 commits into
mainfrom
feat/mcp-auth

Conversation

@nkanu17

@nkanu17 nkanu17 commented Jun 10, 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.

Note

High Risk
Changes authentication and authorization on network-exposed MCP endpoints; misconfiguration could block clients or leave HTTP exposed if --allow-unauthenticated is used.

Overview
Adds optional JWT bearer authentication for RedisVL MCP on HTTP transports (streamable-http, sse), with coarse read/write tool gating. Auth stays off by default; stdio is unchanged.

Runtime behavior: New server.auth / REDISVL_MCP_AUTH_* config resolves at server construction (YAML peek + env overrides) into a FastMCP JWTVerifier with stricter required claims (exp, iat). search-records and upsert-records call ensure_tool_scope using configurable read_scope / write_scope and optional authorization_claim (e.g. roles). Startup fails closed if auth wired at construction disagrees with the loaded config.

CLI hardening: Binding HTTP to a non-loopback host without JWT now errors unless --allow-unauthenticated is set; loopback without auth only warns.

Docs & tests: New mcp_authentication how-to, concept/run-guide updates, sphinxcontrib-mermaid, draft mcp-auth-spec.md, and broad unit/integration coverage over JWT validation and live HTTP transport.

Reviewed by Cursor Bugbot for commit f6c5a7b. Bugbot is set up for automated code reviews on this repo. Configure here.

nkanu17 added 12 commits June 9, 2026 19:59
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.
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.
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.
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.
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.
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.
Add a FORBIDDEN MCP error code for authorization failures, and an
authorization_claim field on MCPAuthConfig (default scp) that selects which
token claim carries authorization values. Some IdPs (Azure AD / Entra) carry
app roles in a roles claim rather than scp/scope. Expose it via
REDISVL_MCP_AUTH_AUTHORIZATION_CLAIM.
Add authorization_values (reads scopes for scp/scope, the named claim
otherwise, normalizing list or space-delimited values), make token_has_scope
claim-aware, and add ensure_tool_scope which reads the current request token
and raises a forbidden error when the configured scope is absent. No-ops when
auth is disabled or no scope is configured.
Gate search-records on read_scope and upsert-records on write_scope using the
configured authorization claim, so a read-only token cannot write even when
the upsert tool is registered.
Over streamable-http: a read-only token can search but is rejected on upsert
while a read+write token can upsert, and authorization carried in a roles
claim gates tools correctly.
Add the authentication guide to the how-to index (card, quick reference, and
toctree), point the mcp.md unauthenticated warning at the new guide, and add an
Authentication and Authorization section to the concept doc. Use the json lexer
for the token example to avoid an unknown-lexer build warning.
Add sphinxcontrib-mermaid to the docs group, register the extension, and route
mermaid fenced blocks through it so the authentication guide's diagrams render
on the docs site (they already render on GitHub).
Copilot AI review requested due to automatic review settings June 10, 2026 00:17
Comment thread redisvl/mcp/auth.py
Comment thread redisvl/mcp/auth.py
@jit-ci

jit-ci Bot commented Jun 10, 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 bearer authentication for RedisVL’s MCP HTTP transports (streamable-http, sse) and introduces coarse per-tool authorization (read vs write) using configurable scopes/claims, while keeping stdio unauthenticated. This improves security for network-exposed MCP deployments and documents the intended gateway boundary for finer-grained enforcement.

Changes:

  • Add server.auth config (MCPAuthConfig) plus env-over-YAML resolution and FastMCP JWTVerifier provider construction, wired into RedisVLMCPServer at instantiation time.
  • Gate search-records / upsert-records by configured read_scope / write_scope using a new FORBIDDEN MCP error code and configurable authorization_claim (scp/scope vs roles).
  • Add unit + integration tests and new documentation (including Mermaid diagrams rendered via sphinxcontrib-mermaid).

Reviewed changes

Copilot reviewed 21 out of 22 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
uv.lock Updates lockfile for new docs dependency and version bump.
pyproject.toml Adds sphinxcontrib-mermaid to docs extras.
redisvl/mcp/config.py Introduces MCPAuthConfig and adds server.auth to server config schema.
redisvl/mcp/auth.py Implements env/YAML auth resolution, provider building, and scope helpers/gates.
redisvl/mcp/settings.py Adds REDISVL_MCP_AUTH_* settings and mapping helper for overrides.
redisvl/mcp/server.py Wires auth provider into FastMCP constructor; stores auth config/enabled state.
redisvl/mcp/errors.py Adds FORBIDDEN error code for authorization failures.
redisvl/mcp/tools/search.py Enforces configured read-scope gate on search-records.
redisvl/mcp/tools/upsert.py Enforces configured write-scope gate on upsert-records.
tests/unit/test_mcp/test_auth_config.py Validates auth config contract and invariants.
tests/unit/test_mcp/test_auth_provider.py Verifies provider construction and helper behavior.
tests/unit/test_mcp/test_auth_resolution.py Ensures env-over-YAML resolution for auth settings.
tests/unit/test_mcp/test_auth_scope.py Tests claim selection and per-tool gating behavior.
tests/unit/test_mcp/test_server_auth.py Confirms server attaches verifier (or not) based on resolved config.
tests/integration/test_mcp/test_auth.py End-to-end JWT verification using real signed RS256 tokens.
tests/integration/test_mcp/test_transport_auth.py Runs HTTP transport and checks auth enforcement + scope gating.
docs/conf.py Enables Mermaid rendering via sphinxcontrib.mermaid + MyST fence routing.
docs/user_guide/how_to_guides/mcp.md Updates HTTP transport security warning to point to auth guide.
docs/user_guide/how_to_guides/mcp_authentication.md New how-to documenting JWT auth, scopes/roles, and gateway boundary.
docs/user_guide/how_to_guides/index.md Adds new auth how-to to the guide index/toctree.
docs/concepts/mcp.md Adds authentication/authorization concepts section.
mcp-auth-spec.md Draft spec documenting design goals, config contract, and rollout.

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

Comment thread redisvl/mcp/config.py
Comment thread redisvl/mcp/server.py Outdated
Comment thread tests/integration/test_mcp/test_transport_auth.py Outdated
Comment thread tests/integration/test_mcp/test_transport_auth.py Outdated
Comment thread tests/integration/test_mcp/test_transport_auth.py Outdated
Comment thread tests/integration/test_mcp/test_transport_auth.py Outdated
Comment thread tests/integration/test_mcp/test_transport_auth.py Outdated
Comment thread tests/integration/test_mcp/test_transport_auth.py Outdated
Comment thread tests/integration/test_mcp/test_transport_auth.py Outdated
nkanu17 added 6 commits June 9, 2026 20:24
Explain that RedisVL MCP is an OAuth resource server: it validates access
tokens from an existing IdP but does not run a login flow or issue tokens.
Note that JWT validation is sufficient for enterprise and agent deployments,
and that an oauth-proxy option can be added later if interactive login is
needed.
Address review: type: jwt now requires issuer (an unset issuer would accept
tokens from any issuer), and an auth block carrying JWT fields without
type: jwt is rejected rather than silently running unauthenticated.
Address review: ensure_tool_scope returned forbidden on stdio (no bearer
token) when read/write scopes were configured, contradicting the documented
'stdio is never authenticated' behavior. A missing access token now skips the
gate; authenticated HTTP transports already reject tokenless requests before
the tool runs.
Address review: serving sse/streamable-http without auth now warns on loopback
and fails closed on non-loopback binds unless --allow-unauthenticated is
passed.
Address review: transport-level rejections assert HTTP 401, tool-level scope
rejections assert ToolError, and the readiness poll uses get_running_loop
instead of the deprecated get_event_loop.
Comment thread redisvl/mcp/auth.py
@nkanu17

nkanu17 commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

@slorello89 mentioned that we should have an expiration time (exp timestamp, iat is issue time)

  • optionally: jwt cant be valid for more than x time (exp-iat <= x)
  • optionally can have a revocation (known revoked tokens, redis sorted set)
  • introspection endpoint that can revoke jwts

@nkanu17 nkanu17 requested review from rbs333 and slorello89 June 10, 2026 14:27

@rbs333 rbs333 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this is a definite improvement on what exists today 👍

I do think we will need to explore more fine tuned tenant controls overtime but reasonable scope.

Address review (@slorello89 + Cursor):

- Require token expiration. FastMCP's verifier only rejects an exp that is
  present and past, so a token with no exp would never expire. Tokens must now
  carry exp and iat (configurable via required_claims / REDISVL_MCP_AUTH_
  REQUIRED_CLAIMS), enforced by a JWTVerifier subclass.
- An explicit env REDISVL_MCP_AUTH_TYPE=none now disables auth even when the
  YAML config defines a server.auth block, instead of raising due to the
  untyped-JWT-fields guard.

Deferred for later discussion: max token lifetime (exp - iat <= x), revocation
lists, and an introspection endpoint.
Copilot AI review requested due to automatic review settings June 10, 2026 15:18
Comment thread redisvl/mcp/server.py

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

Copilot reviewed 24 out of 25 changed files in this pull request and generated 2 comments.

Comment thread tests/unit/test_mcp/test_auth_scope.py
Comment thread docs/user_guide/how_to_guides/mcp.md Outdated
nkanu17 added 2 commits June 10, 2026 11:27
Address review (Cursor): auth is wired at construction before the full config
loads, so a config that becomes readable only after construction (relative path
plus a working-directory change, or a file created later) could leave the HTTP
transport unauthenticated. Resolve the config path to absolute at construction
and re-check at startup, refusing to serve when the wired auth state no longer
matches the loaded config.
Address review (Copilot):
- test_auth_scope monkeypatches fastmcp.server.dependencies, so guard the
  module with pytest.importorskip('fastmcp') to skip rather than error when
  the optional extra is absent.
- Update the mcp.md HTTP warning and --host 0.0.0.0 examples to reflect that
  binding a non-loopback host without auth now fails closed unless
  --allow-unauthenticated is passed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f6c5a7b. Configure here.

Comment thread redisvl/mcp/settings.py
if env_value is not None:
overrides[field] = [
item.strip() for item in env_value.split(",") if item.strip()
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty required claims disable expiry

Medium Severity

Setting REDISVL_MCP_AUTH_REQUIRED_CLAIMS to an empty or whitespace-only value resolves required_claims to an empty list that overrides YAML defaults. With no required claims, _StrictClaimsJWTVerifier skips its presence check, so otherwise valid JWTs without exp can authenticate indefinitely. The same applies to an explicit required_claims: [] in YAML because JWT config validation never requires exp or iat.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f6c5a7b. Configure here.

@nkanu17 nkanu17 added auto:release Create a release when this PR is merged auto:patch Increment the patch version when merged auto:minor Increment the minor version when merged and removed auto:patch Increment the patch version when merged labels Jun 10, 2026
@nkanu17 nkanu17 merged commit b3bfd44 into main Jun 10, 2026
11 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:minor Increment the minor version when merged auto:release Create a release when this PR is merged

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)

3 participants