Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
89a43cf
Allow pre_mcp_call guardrail hooks to mutate outbound MCP headers
noahnistler Mar 17, 2026
94da7e6
Enhance MCPServerManager to support hook-modified arguments and extra…
noahnistler Mar 17, 2026
4af352a
Refactor MCPServerManager to raise HTTPException for extra headers in…
noahnistler Mar 17, 2026
f612533
Allow pre_mcp_call guardrail hooks to mutate outbound MCP headers
noahnistler Mar 17, 2026
8574094
Enhance MCPServerManager to support hook-modified arguments and extra…
noahnistler Mar 17, 2026
296f382
Refactor MCPServerManager to raise HTTPException for extra headers in…
noahnistler Mar 17, 2026
49d43f9
feat(guardrails): add MCPJWTSigner built-in guardrail for zero trust …
ishaan-jaff Mar 17, 2026
9f443a3
Update MCPServerManager to raise HTTPException with status code 400 f…
noahnistler Mar 17, 2026
8574543
fix: address P1 issues in MCPJWTSigner
ishaan-jaff Mar 17, 2026
bb6a9aa
docs: add MCP zero trust auth guide with architecture diagram
ishaan-jaff Mar 17, 2026
5253b6c
merge: resolve conflicts from PR #23889
ishaan-jaff Mar 17, 2026
18fc306
docs: add FastMCP JWT verification guide to zero trust doc
ishaan-jaff Mar 17, 2026
9cceff7
fix: address remaining Greptile review issues (round 2)
ishaan-jaff Mar 17, 2026
8acb06d
fix: address Greptile round 3 feedback
ishaan-jaff Mar 17, 2026
49eb266
feat(mcp_jwt_signer): add verify+re-sign, claim ops, two-token model,…
ishaan-jaff Mar 17, 2026
2ec5983
feat(mcp_jwt_signer): add verify+re-sign, claim ops, two-token model,…
ishaan-jaff Mar 17, 2026
4761382
fix(mcp_jwt_signer): address pre-landing review issues
ishaan-jaff Mar 18, 2026
b69cd4f
docs(mcp_zero_trust): rewrite as use-case guide covering all new JWT …
ishaan-jaff Mar 18, 2026
e019286
fix(mcp_jwt_signer): wire all config.yaml params through initialize_g…
ishaan-jaff Mar 18, 2026
a906052
docs(mcp_zero_trust): add hero image
ishaan-jaff Mar 18, 2026
c6b852b
docs(mcp_zero_trust): apply Linear-style edits
ishaan-jaff Mar 18, 2026
4891286
fix(mcp_jwt_signer): fix algorithm confusion attack + add OIDC discov…
ishaan-jaff Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions docs/my-website/docs/mcp_zero_trust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# MCP Zero Trust Auth (JWT Signer)

![Zero Trust MCP Gateway](/img/mcp_zero_trust_gateway.png)

MCP servers have no built-in way to verify that a request actually came through LiteLLM. Without this guardrail, any client that can reach your MCP server directly can call tools — bypassing your access controls entirely.

`MCPJWTSigner` fixes this. It signs every outbound tool call with a short-lived RS256 JWT. Your MCP server verifies the signature against LiteLLM's public key. Requests that didn't go through LiteLLM have no valid signature and are rejected.

---

## Basic setup

Add the guardrail to your config and point your MCP server at LiteLLM's JWKS endpoint. Every tool call gets a signed JWT automatically — no changes needed on the client side.

```yaml title="config.yaml"
mcp_servers:
- server_name: weather
url: http://localhost:8000/mcp
transport: http

guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
issuer: "https://my-litellm.example.com" # defaults to request base URL
audience: "mcp" # default: "mcp"
ttl_seconds: 300 # default: 300
```

**Bring your own signing key** — recommended for production. Auto-generated keys are lost on restart.

```bash
export MCP_JWT_SIGNING_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
# or point to a file
export MCP_JWT_SIGNING_KEY="file:///secrets/mcp-signing-key.pem"
```

**Build a verified MCP server with [FastMCP](https://gofastmcp.com):**

```python title="weather_server.py"
from fastmcp import FastMCP, Context
from fastmcp.server.auth.providers.jwt import JWTVerifier

auth = JWTVerifier(
jwks_uri="https://my-litellm.example.com/.well-known/jwks.json",
issuer="https://my-litellm.example.com",
audience="mcp",
algorithm="RS256",
)

mcp = FastMCP("weather-server", auth=auth)

@mcp.tool()
async def get_weather(city: str, ctx: Context) -> str:
caller = ctx.client_id # JWT `sub` — the verified user identity
return f"Weather in {city}: sunny, 72°F (requested by {caller})"

if __name__ == "__main__":
mcp.run(transport="http", host="0.0.0.0", port=8000)
```

FastMCP fetches the JWKS automatically and re-fetches when the signing key changes.

LiteLLM publishes OIDC discovery so MCP servers find the key without any manual configuration:

```
GET /.well-known/openid-configuration → { "jwks_uri": "https://<litellm>/.well-known/jwks.json" }
GET /.well-known/jwks.json → { "keys": [{ "kty": "RSA", "alg": "RS256", ... }] }
```

> **Read further only if you need to:** thread a corporate IdP identity into the JWT, enforce specific claims on callers, add custom metadata, use AWS Bedrock AgentCore Gateway, or debug JWT rejections.

---

## Thread IdP identity into MCP JWTs

By default the outbound JWT `sub` is LiteLLM's internal `user_id`. If your users authenticate with Okta, Azure AD, or another IdP, the MCP server sees a LiteLLM-internal ID — not the user's email or employee ID.

With verify+re-sign, LiteLLM validates the incoming IdP token first, then builds the outbound JWT using the real identity claims from that token. The MCP server gets the user's actual identity without ever having to trust the original IdP directly.

```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
issuer: "https://my-litellm.example.com"

# Validate the incoming Bearer token against the IdP
access_token_discovery_uri: "https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration"
verify_issuer: "https://login.microsoftonline.com/{tenant}/v2.0"
verify_audience: "api://my-app"

# Which claim to use for `sub` in the outbound JWT — first non-empty value wins
end_user_claim_sources:
- "token:sub" # from the verified incoming JWT
- "token:email" # fallback to email
- "litellm:user_id" # last resort: LiteLLM's internal user_id
```

If the incoming token is **opaque** (not a JWT — some IdPs issue these), add an introspection endpoint. LiteLLM will POST the token to it (RFC 7662) and use the returned claims:

```yaml
token_introspection_endpoint: "https://idp.example.com/oauth2/introspect"
```

**Supported `end_user_claim_sources` values:**

| Source | Resolves to |
|--------|-------------|
| `token:<claim>` | Any claim from the verified incoming JWT (e.g. `token:sub`, `token:email`, `token:oid`) |
| `litellm:user_id` | LiteLLM's internal user ID |
| `litellm:email` | User email from LiteLLM auth context |
| `litellm:end_user_id` | End-user ID if set separately |
| `litellm:team_id` | Team ID from LiteLLM auth context |

---

## Block callers missing required attributes

Some MCP servers expose sensitive operations that should only be reachable by verified employees — not service accounts, not external API keys. You can enforce this at the LiteLLM layer so the MCP server never receives the request at all.

`required_claims` rejects with `403` if the incoming token is missing any listed claim. `optional_claims` forwards claims that are useful but not mandatory.

```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true

access_token_discovery_uri: "https://idp.example.com/.well-known/openid-configuration"

# Service accounts without `employee_id` are blocked before the tool runs
required_claims:
- "sub"
- "employee_id"

# Forward these into the outbound JWT when present — skipped silently if absent
optional_claims:
- "groups"
- "department"
```

**What the client sees when blocked:**
```json
HTTP 403
{ "error": "MCPJWTSigner: incoming token is missing required claims: ['employee_id']. Configure the IdP to include these claims." }
```

---

## Add custom metadata to every JWT

Your MCP server may need context that LiteLLM doesn't carry natively — which deployment sent the request, a tenant ID, an environment tag. Use claim operations to inject, override, or strip claims from the outbound JWT.

```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true

# add: insert only when the key is not already in the JWT
add_claims:
deployment_id: "prod-us-east-1"
tenant_id: "acme-corp"

# set: always override — even if the claim came from the incoming token
set_claims:
env: "production"

# remove: strip claims the MCP server shouldn't see
remove_claims:
- "nbf" # some validators reject nbf; remove it if yours does
```

Operations run in order — `add_claims` → `set_claims` → `remove_claims`. `set_claims` always wins over `add_claims`; `remove_claims` beats both.

---

## AWS Bedrock AgentCore Gateway

Bedrock AgentCore Gateway uses two separate JWTs: one to authenticate the transport connection and another to authorize tool calls. They need different `aud` values and TTLs — a single JWT won't work for both.

LiteLLM can issue both in one hook and inject them into separate headers:

```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
issuer: "https://my-litellm.example.com"
audience: "mcp-resource" # for the MCP resource layer
ttl_seconds: 300

# Second JWT for the transport channel — same sub/act/scope, different aud + TTL
channel_token_audience: "bedrock-agentcore-gateway"
channel_token_ttl: 60 # transport tokens should be short-lived
```

LiteLLM injects two headers on every tool call:
- `Authorization: Bearer <resource-token>` — audience `mcp-resource`, TTL 300s
- `x-mcp-channel-token: Bearer <channel-token>` — audience `bedrock-agentcore-gateway`, TTL 60s

Both tokens are signed with the same LiteLLM key, so your MCP server only needs to trust one JWKS endpoint.

---

## Control which scopes go into the JWT

By default LiteLLM generates least-privilege scopes per request:
- Tool call → `mcp:tools/call mcp:tools/{name}:call`
- List tools → `mcp:tools/call mcp:tools/list`

If your MCP server does its own scope enforcement and needs a specific format, set `allowed_scopes` to replace auto-generation entirely:

```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true

allowed_scopes:
- "mcp:tools/call"
- "mcp:tools/list"
- "mcp:admin"
```

Every JWT carries exactly those scopes regardless of which tool is being called.

---

## Debug JWT rejections

Your MCP server is returning 401 and you're not sure what's in the JWT. Enable `debug_headers` and LiteLLM adds a `x-litellm-mcp-debug` response header with the key claims that were signed:

```yaml title="config.yaml"
guardrails:
- guardrail_name: mcp-jwt-signer
litellm_params:
guardrail: mcp_jwt_signer
mode: pre_mcp_call
default_on: true
debug_headers: true
```

Response header:
```
x-litellm-mcp-debug: v=1; kid=a3f1b2c4d5e6f708; sub=alice@corp.com; iss=https://my-litellm.example.com; exp=1712345678; scope=mcp:tools/call mcp:tools/get_weather:call
```

Check that `kid` matches what the MCP server fetched from JWKS, `iss`/`aud` match your server's expected values, and `exp` hasn't passed. Disable in production — the header leaks claim metadata.

---

## JWT claims reference

| Claim | Value |
|-------|-------|
| `iss` | `issuer` config value (or request base URL) |
| `aud` | `audience` config value (default: `"mcp"`) |
| `sub` | Resolved via `end_user_claim_sources` (default: `user_id` → api-key hash → `"litellm-proxy"`) |
| `act.sub` | `team_id` → `org_id` → `"litellm-proxy"` (RFC 8693 delegation) |
| `email` | `user_email` from LiteLLM auth context (when available) |
| `scope` | Auto-generated per tool call, or `allowed_scopes` when set |
| `iat`, `exp`, `nbf` | Standard timing claims (RFC 7519) |

---

## Limitations

- **OpenAPI-backed MCP servers** (`spec_path` set) do not support JWT injection. LiteLLM logs a warning and skips the header. Use SSE/HTTP transport servers to get full JWT injection.
- The keypair is **in-memory by default** and rotated on each restart unless `MCP_JWT_SIGNING_KEY` is set. FastMCP's `JWTVerifier` handles key rotation transparently via JWKS key ID matching.

---

## Related

- [MCP Guardrails](./mcp_guardrail) — PII masking and blocking for MCP calls
- [MCP OAuth](./mcp_oauth) — upstream OAuth2 for MCP server access
- [MCP AWS SigV4](./mcp_aws_sigv4) — AWS-signed requests to MCP servers
Binary file added docs/my-website/img/mcp_zero_trust_gateway.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/my-website/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ const sidebars = {
"mcp_control",
"mcp_cost",
"mcp_guardrail",
"mcp_zero_trust",
"mcp_troubleshoot",
]
},
Expand Down
55 changes: 54 additions & 1 deletion litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,60 @@ async def oauth_authorization_server_mcp(
# Alias for standard OpenID discovery
@router.get("/.well-known/openid-configuration")
async def openid_configuration(request: Request):
return await oauth_authorization_server_mcp(request)
response = await oauth_authorization_server_mcp(request)

# If MCPJWTSigner is active, augment the discovery doc with JWKS fields so
# MCP servers and gateways (e.g. AWS Bedrock AgentCore Gateway) can resolve
# the signing keys and verify liteLLM-issued tokens.
try:
from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
get_mcp_jwt_signer,
)

signer = get_mcp_jwt_signer()
if signer is not None:
request_base_url = get_request_base_url(request)
if isinstance(response, dict):
response = {
**response,
"jwks_uri": f"{request_base_url}/.well-known/jwks.json",
"id_token_signing_alg_values_supported": ["RS256"],
}
except ImportError:
pass

return response


@router.get("/.well-known/jwks.json")
async def jwks_json(request: Request):
"""
JSON Web Key Set endpoint.

Returns the RSA public key used by MCPJWTSigner to sign outbound MCP tokens.
MCP servers and gateways use this endpoint to verify liteLLM-issued JWTs.

Returns an empty key set if MCPJWTSigner is not configured.
"""
try:
from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
get_mcp_jwt_signer,
)

signer = get_mcp_jwt_signer()
if signer is not None:
return JSONResponse(
content=signer.get_jwks(),
headers={"Cache-Control": f"public, max-age={signer.jwks_max_age}"},
)
except ImportError:
pass

# No signer active — return empty key set; short cache so activation is picked up quickly.
return JSONResponse(
content={"keys": []},
headers={"Cache-Control": "public, max-age=60"},
)


# Additional legacy pattern support
Expand Down
Loading
Loading