-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
feat(guardrails): MCPJWTSigner - built-in guardrail for zero trust MCP auth #23897
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
89a43cf
94da7e6
4af352a
f612533
8574094
296f382
49d43f9
9f443a3
8574543
bb6a9aa
5253b6c
18fc306
9cceff7
8acb06d
49eb266
2ec5983
4761382
b69cd4f
e019286
a906052
c6b852b
4891286
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"] | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The endpoint returns For auto-generated keys, either:
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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
|
@@ -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( | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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. | ||
|
|
@@ -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 | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The OpenAPI code path received a warning ( At minimum, add a warning similar to the OpenAPI path when the hook header would override an existing 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( | ||
|
|
@@ -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 = [] | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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) | ||
|
|
@@ -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 | ||
|
|
||
| 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 | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
External callers should only use from .mcp_jwt_signer import MCPJWTSigner, get_mcp_jwt_signer
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| 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", | ||||||||||||||
| ] | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oauth_authorization_server_mcp(request)returns its result viaresponse, and this code then mutates it in-place withresponse["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 theMCPJWTSignersingleton is unregistered.Use a defensive copy: