Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
99 changes: 99 additions & 0 deletions docs/my-website/docs/mcp_zero_trust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# MCP Zero Trust Auth (JWT Signer)

The `MCPJWTSigner` guardrail signs every outbound MCP tool call with a LiteLLM-issued RS256 JWT. MCP servers validate tokens against LiteLLM's JWKS endpoint instead of trusting each upstream IdP directly.

## Architecture

```mermaid
sequenceDiagram
participant Client
participant LiteLLM
participant JWKS as LiteLLM JWKS<br/>/.well-known/jwks.json
participant MCP as MCP Server

Client->>LiteLLM: tool call (Bearer API key / JWT)
Note over LiteLLM: MCPJWTSigner.async_pre_call_hook()<br/>builds RS256 JWT:<br/>sub=user_id, act=team_id,<br/>scope=mcp:tools/{name}:call

LiteLLM->>MCP: call_tool(args)<br/>Authorization: Bearer <litellm-jwt>
MCP->>JWKS: GET /.well-known/jwks.json
JWKS-->>MCP: RSA public key (JWKS)
MCP->>MCP: verify JWT signature + claims
MCP-->>LiteLLM: tool result
LiteLLM-->>Client: response
```

### OIDC Discovery

LiteLLM publishes standard OIDC discovery so MCP servers can find the signing key automatically:

```
GET /.well-known/openid-configuration
→ { "jwks_uri": "https://<your-litellm>/.well-known/jwks.json", ... }

GET /.well-known/jwks.json
→ { "keys": [{ "kty": "RSA", "alg": "RS256", "kid": "...", "n": "...", "e": "..." }] }
```

## Setup

### 1. Enable in `config.yaml`

```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" # optional — defaults to request base URL
audience: "mcp" # optional — default: "mcp"
ttl_seconds: 300 # optional — default: 300
```

### 2. (Optional) Bring your own RSA key

If unset, LiteLLM auto-generates an RSA-2048 keypair at startup (lost on restart).

```bash
# PEM string
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"
```

### 3. Configure your MCP server to verify tokens

Point your MCP server at LiteLLM's OIDC discovery endpoint:

```
https://<your-litellm>/.well-known/openid-configuration
```

Most JWT middleware (e.g. `python-jose`, `jsonwebtoken`, AWS Lambda authorizers) supports OIDC auto-discovery.

## JWT Claims

| Claim | Value | RFC |
|-------|-------|-----|
| `iss` | LiteLLM issuer URL | RFC 7519 |
| `aud` | configured `audience` | RFC 7519 |
| `sub` | `user_api_key_dict.user_id` | RFC 8693 |
| `act.sub` | `team_id` → `org_id` → `"litellm-proxy"` | RFC 8693 delegation |
| `email` | `user_api_key_dict.user_email` (if set) | — |
| `scope` | `mcp:tools/call mcp:tools/list mcp:tools/{name}:call` | — |
| `iat`, `exp`, `nbf` | standard timing | RFC 7519 |

## Limitations

- **OpenAPI-backed MCP servers** (`spec_path` set) do not support hook header injection. Calls to these servers will fail with a 500 if `MCPJWTSigner` is active with `default_on: true`. Use SSE/HTTP transport MCP servers instead.
- The keypair is **in-memory by default** — rotated on every restart unless `MCP_JWT_SIGNING_KEY` is set. MCP servers should re-fetch JWKS on verification failure (short JWKS cache TTL recommended).

## 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
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
52 changes: 51 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,57 @@ 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["jwks_uri"] = f"{request_base_url}/.well-known/jwks.json"
response["id_token_signing_alg_values_supported"] = ["RS256"]

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.

P2 In-place mutation of a potentially shared response object

oauth_authorization_server_mcp(request) returns its result via response, and this code then mutates it in-place with response["jwks_uri"] = .... If the underlying function ever returns a cached or shared dict object (e.g., from a module-level constant or cached response), this mutation could permanently alter the base OIDC document for all subsequent requests — even after the MCPJWTSigner singleton is unregistered.

Use a defensive copy:

if isinstance(response, dict):
    response = {**response}  # shallow copy to avoid mutating a shared object
    response["jwks_uri"] = f"{request_base_url}/.well-known/jwks.json"
    response["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}"},
)
Comment on lines +722 to +725

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.

P1 JWKS cache duration too long for auto-generated ephemeral keys

The endpoint returns Cache-Control: public, max-age=3600 regardless of whether the key is a user-supplied persistent key or an auto-generated ephemeral keypair. When the server restarts (which regenerates the keypair), downstream MCP servers and gateways will continue using the old cached JWKS for up to one hour, causing all JWT verifications to fail during that window.

For auto-generated keys, either:

  • Reduce the cache TTL significantly (e.g., max-age=60), or
  • Use Cache-Control: no-store (or private, no-cache) for ephemeral keys and only apply long-lived caching when a persistent key from MCP_JWT_SIGNING_KEY env var is used.
signer = get_mcp_jwt_signer()
if signer is not None:
    key_is_ephemeral = not os.environ.get(signer.SIGNING_KEY_ENV)
    cache_ttl = 60 if key_is_ephemeral else 3600
    return JSONResponse(
        content=signer.get_jwks(),
        headers={"Cache-Control": f"public, max-age={cache_ttl}"},
    )

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
41 changes: 37 additions & 4 deletions litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1908,7 +1908,14 @@ async def pre_call_tool_check(
user_api_key_auth: Optional[UserAPIKeyAuth],
proxy_logging_obj: ProxyLogging,
server: MCPServer,
):
) -> Dict[str, Any]:
"""
Run pre-call checks and guardrail hooks for an MCP tool call.

Returns a dict that may contain:
- "arguments": hook-modified tool arguments (only if changed)
- "extra_headers": headers injected by pre_mcp_call guardrail hooks
"""
## check if the tool is allowed or banned for the given server
if not self.check_allowed_or_banned_tools(name, server):
raise HTTPException(
Expand Down Expand Up @@ -1969,6 +1976,7 @@ async def pre_call_tool_check(
mcp_request_obj, pre_hook_kwargs
)

hook_result: Dict[str, Any] = {}
try:
# Use standard pre_call_hook
modified_data = await proxy_logging_obj.pre_call_hook(
Expand All @@ -1984,7 +1992,9 @@ async def pre_call_tool_check(
)
)
if modified_kwargs.get("arguments") != arguments:
arguments = modified_kwargs["arguments"]
hook_result["arguments"] = modified_kwargs["arguments"]
if modified_kwargs.get("extra_headers"):
hook_result["extra_headers"] = modified_kwargs["extra_headers"]

except (
BlockedPiiEntityError,
Expand All @@ -1995,6 +2005,8 @@ async def pre_call_tool_check(
verbose_logger.error(f"Guardrail blocked MCP tool call pre call: {str(e)}")
raise e

return hook_result

def _create_during_hook_task(
self,
name: str,
Expand Down Expand Up @@ -2047,6 +2059,7 @@ async def _call_regular_mcp_tool(
raw_headers: Optional[Dict[str, str]],
proxy_logging_obj: Optional[ProxyLogging],
host_progress_callback: Optional[Callable] = None,
hook_extra_headers: Optional[Dict[str, str]] = None,
) -> CallToolResult:
"""
Call a regular MCP tool using the MCP client.
Expand All @@ -2061,6 +2074,9 @@ async def _call_regular_mcp_tool(
oauth2_headers: Optional OAuth2 headers
raw_headers: Optional raw headers from the request
proxy_logging_obj: Optional ProxyLogging object for hook integration
host_progress_callback: Optional callback for progress updates
hook_extra_headers: Optional headers injected by pre_mcp_call guardrail
hooks. Merged last (highest priority) into outbound request headers.

Returns:
CallToolResult from the MCP server
Expand Down Expand Up @@ -2116,6 +2132,11 @@ async def _call_regular_mcp_tool(
extra_headers = {}
extra_headers.update(mcp_server.static_headers)

if hook_extra_headers:
if extra_headers is None:
extra_headers = {}
extra_headers.update(hook_extra_headers)
Comment on lines +2145 to +2168

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.

P1 Hook JWT silently overwrites existing OAuth2/static Authorization header on regular MCP servers

hook_extra_headers is merged last (highest priority). When MCPJWTSigner is enabled with default_on: true, its Authorization: Bearer <jwt> overwrites any Authorization header already set by OAuth2 exchange or mcp_server_auth_headers — silently breaking authentication for any regular (non-OpenAPI) MCP server that already has its own auth configured.

The OpenAPI code path received a warning (verbose_logger.warning(...)) for this case, but the regular MCP code path has no equivalent guard. Users enabling this guardrail against servers with existing OAuth2 or API-key auth will see silent authentication failures.

At minimum, add a warning similar to the OpenAPI path when the hook header would override an existing Authorization:

if hook_extra_headers:
    if extra_headers is None:
        extra_headers = {}
    if "Authorization" in hook_extra_headers and "Authorization" in extra_headers:
        verbose_logger.warning(
            "MCPJWTSigner: overriding existing Authorization header for "
            "MCP server '%s'. Disable MCPJWTSigner or remove server-level "
            "auth if this is unintended.",
            mcp_server.server_name,
        )
    extra_headers.update(hook_extra_headers)


stdio_env = self._build_stdio_env(mcp_server, raw_headers)

client = await self._create_mcp_client(
Expand Down Expand Up @@ -2201,15 +2222,18 @@ async def call_tool(
# Allow validation and modification of tool calls before execution
# Using standard pre_call_hook
#########################################################
hook_result: Dict[str, Any] = {}
if proxy_logging_obj:
await self.pre_call_tool_check(
hook_result = await self.pre_call_tool_check(
name=name,
arguments=arguments,
server_name=server_name,
user_api_key_auth=user_api_key_auth,
proxy_logging_obj=proxy_logging_obj,
server=mcp_server,
)
if "arguments" in hook_result:
arguments = hook_result["arguments"]

# Prepare tasks for during hooks
tasks = []
Expand All @@ -2227,8 +2251,16 @@ async def call_tool(
# For OpenAPI servers, call the tool handler directly instead of via MCP client
if mcp_server.spec_path:
verbose_logger.debug(
f"Calling OpenAPI tool {name} directly via HTTP handler"
"Calling OpenAPI tool %s directly via HTTP handler", name
)
if hook_result.get("extra_headers"):
verbose_logger.warning(
"pre_mcp_call hook returned extra_headers for OpenAPI-backed "
"MCP server '%s' — header injection is not supported for "
"OpenAPI servers; headers will be ignored. Use SSE/HTTP "
"transport to enable hook header injection.",
server_name,
)
Comment on lines +2287 to +2294

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.

P1 Breaking 500 error for OpenAPI-backed MCP servers when guardrail is active

When MCPJWTSigner is configured with default_on: true, it will inject extra_headers for every call_mcp_tool invocation. Any user with an OpenAPI-backed MCP server (spec_path set) will immediately hit a 500 Internal Server Error simply because the guardrail is enabled. This is a breaking change for existing OpenAPI MCP server users who add this guardrail.

The PR description claims "No breaking changes — purely additive", but this contradicts that: any deployment that enables the guardrail AND has at least one OpenAPI-backed server will see tool calls fail.

A more resilient approach would be to silently skip header injection for OpenAPI servers (log a warning instead of raising), matching the "graceful degradation" principle for guardrails:

if hook_result.get("extra_headers"):
    if mcp_server.spec_path:
        verbose_logger.warning(
            "MCPJWTSigner: OpenAPI-backed server '%s' does not support hook header "
            "injection — skipping JWT signing for this call.",
            mcp_server.server_name,
        )
    else:
        raise HTTPException(...)

tasks.append(
asyncio.create_task(
self._call_openapi_tool_handler(mcp_server, name, arguments)
Expand All @@ -2247,6 +2279,7 @@ async def call_tool(
raw_headers=raw_headers,
proxy_logging_obj=proxy_logging_obj,
host_progress_callback=host_progress_callback,
hook_extra_headers=hook_result.get("extra_headers"),
)

# For OpenAPI tools, await outside the client context
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2471,6 +2471,7 @@ class UserAPIKeyAuth(
Any
] = None # Expanded created_by user when expand=user is used
end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
jwt_claims: Optional[Dict] = None

model_config = ConfigDict(arbitrary_types_allowed=True)

Expand Down
4 changes: 4 additions & 0 deletions litellm/proxy/auth/user_api_key_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
)
if valid_token is not None:
api_key = valid_token.token or ""
valid_token.jwt_claims = jwt_claims
do_standard_jwt_auth = False
# Fall through to virtual key checks

Expand Down Expand Up @@ -729,6 +730,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
team_membership: Optional[LiteLLM_TeamMembership] = result.get(
"team_membership", None
)
jwt_claims: Optional[dict] = result.get("jwt_claims", None)

global_proxy_spend = await get_global_proxy_spend(
litellm_proxy_admin_name=litellm_proxy_admin_name,
Expand Down Expand Up @@ -757,6 +759,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
org_id=org_id,
end_user_id=end_user_id,
parent_otel_span=parent_otel_span,
jwt_claims=jwt_claims,
)

valid_token = UserAPIKeyAuth(
Expand Down Expand Up @@ -803,6 +806,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
team_metadata=(
team_object.metadata if team_object is not None else None
),
jwt_claims=jwt_claims,
)

# Check if model has zero cost - if so, skip all budget checks
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""MCP JWT Signer guardrail — built-in LiteLLM guardrail for zero trust MCP auth."""

from typing import TYPE_CHECKING

from litellm.types.guardrails import SupportedGuardrailIntegrations

from .mcp_jwt_signer import MCPJWTSigner, _mcp_jwt_signer_instance, get_mcp_jwt_signer

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.

P2 Private module variable _mcp_jwt_signer_instance exported as public API

_mcp_jwt_signer_instance is a private implementation detail (the leading _ convention signals this). Re-exporting it in __all__ makes it part of the package's public API, which may cause unintended external usage or direct mutation of the singleton from outside the module.

External callers should only use get_mcp_jwt_signer() (the public accessor). Remove _mcp_jwt_signer_instance from the import and __all__:

from .mcp_jwt_signer import MCPJWTSigner, get_mcp_jwt_signer
Suggested change
from .mcp_jwt_signer import MCPJWTSigner, _mcp_jwt_signer_instance, get_mcp_jwt_signer
__all__ = [
"MCPJWTSigner",
"initialize_guardrail",
"get_mcp_jwt_signer",
]


if TYPE_CHECKING:
from litellm.types.guardrails import Guardrail, LitellmParams


def initialize_guardrail(
litellm_params: "LitellmParams", guardrail: "Guardrail"
) -> MCPJWTSigner:
import litellm

guardrail_name = guardrail.get("guardrail_name")
if not guardrail_name:
raise ValueError("MCPJWTSigner guardrail requires a guardrail_name")

optional_params = getattr(litellm_params, "optional_params", None)

def _get(key): # type: ignore[no-untyped-def]
if optional_params is not None:
v = getattr(optional_params, key, None)
if v is not None:
return v
return getattr(litellm_params, key, None)

signer = MCPJWTSigner(
guardrail_name=guardrail_name,
event_hook=litellm_params.mode,
default_on=litellm_params.default_on,
issuer=_get("issuer"),
audience=_get("audience"),
ttl_seconds=_get("ttl_seconds"),
)
litellm.logging_callback_manager.add_litellm_callback(signer)
return signer


guardrail_initializer_registry = {
SupportedGuardrailIntegrations.MCP_JWT_SIGNER.value: initialize_guardrail,
}

guardrail_class_registry = {
SupportedGuardrailIntegrations.MCP_JWT_SIGNER.value: MCPJWTSigner,
}

__all__ = [
"MCPJWTSigner",
"initialize_guardrail",
"_mcp_jwt_signer_instance",
"get_mcp_jwt_signer",
]
Loading
Loading