fix(mcp): JWT on tools/list and REST tools/call server resolution (#28227)

* fix(mcp): JWT on tools/list, REST server_id resolution, tool_server_mismatch

Sign outbound MCP JWTs for list_mcp_tools and inject headers on the tools/list
path. Resolve server_id on /mcp-rest/tools/call and return 403 tool_server_mismatch
when the tool does not belong to the requested server. Default missing arguments to {}.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): restrict list JWTs to mcp:tools/list and default REST arguments to {}

- List-only JWTs (call_type=list_mcp_tools) no longer carry the broad
  mcp:tools/call scope. _build_scope() now emits only mcp:tools/list
  when no tool name is provided, mirroring the existing least-privilege
  rule that tool-call JWTs omit mcp:tools/list.
- REST /tools/call now defaults a missing 'arguments' field to {} so
  execute_mcp_tool() and downstream **arguments / .keys() calls don't
  receive None and crash with TypeError/AttributeError.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(mcp): validate tool/server in call_tool; skip JWT signer when not configured or static auth present

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(mcp): align tests and mypy with user_api_key_auth on tools/list

Update mocks for the new _get_tools_from_server parameter, mock server
registry in REST access-denied test, and narrow static_headers for mypy.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(test): accept user_api_key_auth in get_tools_from_mcp_servers mock

The side_effect for the all-servers case did not accept the new kwarg,
so tools/list returned an empty list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): fail fast for unknown tools when server mapping exists

Server-name fallback in call_tool must not open an upstream session when
the tool is absent from a populated mapping. Update the HTTP transport test
to register a known tool before asserting not-found behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix mypy

* Fix mypy

* fix(mcp): preserve tools/call scope on missing tool name; pass user_api_key_auth in list_tools

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(mcp): match alias/server_name in _resolve_mcp_server_for_tool_call

The registry lookup in _resolve_mcp_server_for_tool_call previously only
compared candidate.name against the provided server_name, but tool name
prefixes can be derived from a server's alias or server_name (see
get_server_prefix). When the tool→server mapping is empty/stale (cold
start, dynamic tools), the lookup would fail for alias-configured
servers even though get_mcp_server_by_name (used by the REST path)
matches alias, server_name, and name.

Match the same priority of identifiers in both the registry pass and
the unprefixed fallback so the MCP protocol call_tool path is
consistent with the REST path.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(mcp): reuse proxy_logging DualCache in inject_mcp_jwt_headers_for_upstream

Instead of allocating a fresh DualCache() on every tools/list invocation,
prefer the shared proxy_logging_obj.internal_usage_cache.dual_cache when
available. The cache argument is currently unused by MCPJWTSigner, but
sharing the proxy's cache avoids per-call allocation overhead and matches
the cache identity used elsewhere in the proxy hook plumbing — so any
future per-request state stored in cache will survive across list calls.

Co-authored-by: Claude <noreply@anthropic.com>

* fix(mcp): return 403 ip_filtering for IP-restricted servers in tools/call name lookup

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(test): accept user_api_key_auth kwarg in list_tools mocks

The proxy-infra job was failing on four TestMCPServerManager tests because
the mock_get_tools_from_server stubs did not accept the new
user_api_key_auth keyword argument that list_tools now forwards to
_get_tools_from_server. Add the kwarg to each stub so list_tools can call
through cleanly.

Co-authored-by: Claude <claude@anthropic.com>

* fix(mcp): skip JWT injection when per-user mcp_auth_header is set

MCPClient._get_auth_headers() applies extra_headers AFTER writing
Authorization from auth_value, so an injected JWT silently overwrites
the user's per-server OAuth token. Guard the JWT signer with
'not mcp_auth_header' so per-user OAuth (and any dict-form per-user
auth) takes precedence, mirroring the existing static_headers guard.

Adds a regression test that the signer's inject helper is not called
when mcp_auth_header is supplied.

* fix(mcp): skip JWT injection when extra_headers already has Authorization

When a server uses per-user OAuth tokens, the resolved token is passed
into _get_tools_from_server via extra_headers. The JWT injection guard
only checked mcp_auth_header and the server's static headers, so the
signer would silently overwrite the user's OAuth Authorization header.

Add a check for an existing Authorization entry in extra_headers so
caller-supplied per-user OAuth tokens take precedence over JWT signing.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* test(mcp): cover JWT signer + tool-call resolution branches

Adds unit tests for the new MCPServerManager helpers (_resolve_mcp_server_for_tool_call,
_resolve_oauth2_headers_for_tool_call) and the new MCPJWTSigner paths
(_build_scope call_type branches and inject_mcp_jwt_headers_for_upstream).
Brings patch coverage above the auto target without changing behavior.

Co-authored-by: Claude <claude@anthropic.com>

* fix(mcp): retry tool-server lookup with prefixed name in REST mismatch check

When the REST /mcp-rest/tools/call path sends a raw tool name plus
requested_server_id, _get_mcp_server_from_tool_name(name) can return
None if the mapping only stores the prefixed form. That bypassed the
tool_server_mismatch 403 guard and let the call fall through to
trusting requested_server.

Retry the lookup with every known prefix of the requested server so
the mismatch check fires whenever the tool is actually registered.

Co-authored-by: Yassin Kortam <yassin@berri.ai>

* fix(mcp): always reject unknown tools in server-name fallback

Defense-in-depth: _resolve_mcp_server_for_tool_call previously skipped
the unknown-tool check whenever the per-server mapping had no entries
yet (cold start, OAuth2 lazy listing, or upstream listing failure),
allowing arbitrary tool names to reach upstream servers.

Tighten the check so the server-name fallback always rejects tool
names not present in the mapping. Callers must call list_tools first
(standard MCP flow) before tools/call can resolve. Removes the
now-unused _mapping_has_tools_for_server helper and adds an
explicit empty-mapping rejection test alongside the existing
populated-mapping rejection test.

Co-authored-by: Sameer Kankute <sameer@berri.ai>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Yassin Kortam <yassin@berri.ai>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude (greptile subagent) <claude-greptile-bot@anthropic.com>
This commit is contained in:
Sameer Kankute
2026-05-20 13:31:44 -07:00
committed by GitHub
co-authored by Cursor Yassin Kortam Claude Claude Claude
parent fb73995c40
commit 68efe6970c
44 changed files with 804 additions and 83 deletions
@@ -1226,6 +1226,7 @@ class MCPServerManager:
tools = await self._get_tools_from_server(
server=server,
mcp_auth_header=server_auth_header,
user_api_key_auth=user_api_key_auth,
)
return tools
except Exception as e:
@@ -1406,6 +1407,7 @@ class MCPServerManager:
extra_headers: Optional[Dict[str, str]] = None,
add_prefix: bool = True,
raw_headers: Optional[Dict[str, str]] = None,
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
) -> List[MCPTool]:
"""
Helper method to get tools from a single MCP server with prefixed names.
@@ -1432,6 +1434,46 @@ class MCPServerManager:
extra_headers = {}
extra_headers.update(server.static_headers)
# MCPJWTSigner: inject signed JWT for tools/list (list path skips pre_call_hook).
# Skip entirely when the signer is not configured (avoid an unnecessary
# dict copy on every list call), when the server has its own static
# Authorization header, when a per-user mcp_auth_header has already
# been resolved, or when the caller already supplied an Authorization
# entry in extra_headers (e.g. a per-user OAuth token resolved
# upstream) — admin-configured static auth and per-user OAuth must
# take precedence so the signer doesn't silently overwrite e.g. an
# upstream API key or a user's OAuth token (MCPClient._get_auth_headers
# applies extra_headers after writing Authorization from auth_value, so
# an injected JWT would otherwise clobber the per-user token).
if user_api_key_auth is not None and not server.spec_path:
from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
get_mcp_jwt_signer,
inject_mcp_jwt_headers_for_upstream,
)
static_headers = server.static_headers or {}
has_static_authorization = any(
isinstance(k, str) and k.lower() == "authorization"
for k in static_headers.keys()
)
has_extra_authorization = bool(extra_headers) and any(
isinstance(k, str) and k.lower() == "authorization"
for k in (extra_headers or {}).keys()
)
if (
get_mcp_jwt_signer() is not None
and not has_static_authorization
and not mcp_auth_header
and not has_extra_authorization
):
extra_headers = await inject_mcp_jwt_headers_for_upstream(
user_api_key_dict=user_api_key_auth,
extra_headers=extra_headers,
raw_headers=raw_headers,
for_list_tools=True,
)
stdio_env = self._build_stdio_env(server, raw_headers)
client = await self._create_mcp_client(
@@ -2791,6 +2833,112 @@ class MCPServerManager:
return cast(CallToolResult, result)
def _resolve_mcp_server_for_tool_call(
self,
server_name: str,
name: str,
) -> MCPServer:
"""Resolve MCP server for call_tool (prefixed name, registry, fallback)."""
prefixed_tool_name = add_server_prefix_to_name(name, server_name)
mcp_server = self._get_mcp_server_from_tool_name(prefixed_tool_name)
resolved_by_server_name_only = False
normalized_server_name = normalize_server_name(server_name)
def _candidate_matches_server_name(candidate: MCPServer) -> bool:
for identifier in (
candidate.alias,
candidate.server_name,
candidate.name,
):
if identifier and normalize_server_name(identifier) == (
normalized_server_name
):
return True
return False
if mcp_server is None:
for candidate in self.get_registry().values():
if _candidate_matches_server_name(candidate):
mcp_server = candidate
resolved_by_server_name_only = True
break
if mcp_server is None:
fallback = self._get_mcp_server_from_tool_name(name)
if fallback is not None and (
not server_name or _candidate_matches_server_name(fallback)
):
mcp_server = fallback
if mcp_server is None:
raise ValueError(f"Tool {name} not found")
if resolved_by_server_name_only:
tool_known = (
name in self.tool_name_to_mcp_server_name_mapping
or prefixed_tool_name in self.tool_name_to_mcp_server_name_mapping
)
if not tool_known:
raise ValueError(f"Tool {name} not found")
return mcp_server
async def _resolve_oauth2_headers_for_tool_call(
self,
mcp_server: MCPServer,
oauth2_headers: Optional[Dict[str, str]],
user_api_key_auth: Optional[UserAPIKeyAuth],
) -> Optional[Dict[str, str]]:
"""Look up per-user OAuth headers when the client did not supply a token."""
if (
not mcp_server.needs_user_oauth_token
or oauth2_headers
or user_api_key_auth is None
):
return oauth2_headers
user_id = getattr(user_api_key_auth, "user_id", None)
if not user_id:
return oauth2_headers
try:
from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415
_get_user_oauth_extra_headers_from_db,
)
stored_headers = await _get_user_oauth_extra_headers_from_db(
server=mcp_server,
user_api_key_auth=user_api_key_auth,
)
if stored_headers:
return stored_headers
except Exception as _lookup_exc:
verbose_logger.debug(
"call_tool: per-user token lookup failed for " "user=%s server=%s: %s",
user_id,
mcp_server.server_id,
_lookup_exc,
)
return oauth2_headers
async def _gather_openapi_tool_tasks(
self,
tasks: List[Any],
proxy_logging_obj: Optional[ProxyLogging],
) -> CallToolResult:
"""Await OpenAPI tool tasks and return the tool call result."""
try:
mcp_responses = await asyncio.gather(*tasks)
result_index = 1 if proxy_logging_obj else 0
return cast(CallToolResult, mcp_responses[result_index])
except (
BlockedPiiEntityError,
GuardrailRaisedException,
HTTPException,
) as e:
verbose_logger.error(
f"Guardrail blocked MCP tool call during result check: {str(e)}"
)
raise e
async def call_tool(
self,
server_name: str,
@@ -2821,12 +2969,7 @@ class MCPServerManager:
CallToolResult from the MCP server
"""
start_time = datetime.datetime.now()
# Get the MCP server
prefixed_tool_name = add_server_prefix_to_name(name, server_name)
mcp_server = self._get_mcp_server_from_tool_name(prefixed_tool_name)
if mcp_server is None:
raise ValueError(f"Tool {name} not found")
mcp_server = self._resolve_mcp_server_for_tool_call(server_name, name)
#########################################################
# Pre MCP Tool Call Hook
@@ -2860,36 +3003,9 @@ class MCPServerManager:
)
tasks.append(during_hook_task)
# For per-user OAuth servers: if the client didn't supply a token in
# oauth2_headers, look up the stored token from Redis / DB. This is the
# call_tool equivalent of _get_user_oauth_extra_headers_from_db used in
# list_tools.
if (
mcp_server.needs_user_oauth_token
and not oauth2_headers
and user_api_key_auth is not None
):
user_id = getattr(user_api_key_auth, "user_id", None)
if user_id:
try:
from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415
_get_user_oauth_extra_headers_from_db,
)
stored_headers = await _get_user_oauth_extra_headers_from_db(
server=mcp_server,
user_api_key_auth=user_api_key_auth,
)
if stored_headers:
oauth2_headers = stored_headers
except Exception as _lookup_exc:
verbose_logger.debug(
"call_tool: per-user token lookup failed for "
"user=%s server=%s: %s",
user_id,
mcp_server.server_id,
_lookup_exc,
)
oauth2_headers = await self._resolve_oauth2_headers_for_tool_call(
mcp_server, oauth2_headers, user_api_key_auth
)
# For OpenAPI servers, call the tool handler directly instead of via MCP client
if mcp_server.spec_path:
@@ -2925,26 +3041,7 @@ class MCPServerManager:
hook_extra_headers=hook_result.get("extra_headers"),
)
# For OpenAPI tools, await outside the client context
try:
mcp_responses = await asyncio.gather(*tasks)
# If proxy_logging_obj is None, the tool call result is at index 0
# If proxy_logging_obj is not None, the tool call result is at index 1 (after the during hook task)
result_index = 1 if proxy_logging_obj else 0
result = mcp_responses[result_index]
return cast(CallToolResult, result)
except (
BlockedPiiEntityError,
GuardrailRaisedException,
HTTPException,
) as e:
# Re-raise guardrail exceptions to properly fail the MCP call
verbose_logger.error(
f"Guardrail blocked MCP tool call during result check: {str(e)}"
)
raise e
return await self._gather_openapi_tool_tasks(tasks, proxy_logging_obj)
#########################################################
# End of Methods that call the upstream MCP servers
@@ -1,6 +1,17 @@
import importlib
from datetime import datetime
from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Set, Union
from typing import (
Any,
Awaitable,
Callable,
Dict,
List,
Literal,
Optional,
Set,
Tuple,
Union,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -231,11 +242,32 @@ if MCP_AVAILABLE:
)
return mcp_auth_header, mcp_server_auth_headers, raw_headers
def _resolve_mcp_server_id_for_rest(
server_id: str,
allowed_server_ids: Union[Set[str], List[str]],
client_ip: Optional[str] = None,
) -> str:
"""
Map REST ``server_id`` (UUID, server_name, or alias) to canonical server_id.
tools/list already did this; tools/call must match so clients can pass
server names like ``order_status_mcp`` instead of only UUIDs.
"""
allowed = set(allowed_server_ids)
if server_id in allowed:
return server_id
by_name = global_mcp_server_manager.get_mcp_server_by_name(
server_id, client_ip=client_ip
)
if by_name is not None and by_name.server_id in allowed:
return by_name.server_id
return server_id
async def _resolve_allowed_mcp_servers_with_ip_filter(
request: Request,
user_api_key_dict: UserAPIKeyAuth,
server_id: str,
) -> List[MCPServer]:
) -> Tuple[List[MCPServer], str]:
"""
Resolve allowed MCP servers for a tool call with IP filtering.
@@ -245,10 +277,10 @@ if MCP_AVAILABLE:
server_id: The server ID to validate access for
Returns:
List of allowed MCPServer objects
Tuple of (allowed MCPServer objects, canonical server_id)
Raises:
HTTPException: If the server_id is not allowed
HTTPException: If the server_id is not allowed or not found
"""
# Get all auth contexts
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
@@ -268,8 +300,41 @@ if MCP_AVAILABLE:
)
)
# Check if the specified server_id is allowed
if server_id not in allowed_server_ids_set:
canonical_server_id = _resolve_mcp_server_id_for_rest(
server_id, allowed_server_ids_set, _rest_client_ip
)
if canonical_server_id not in allowed_server_ids_set:
_server = global_mcp_server_manager.get_mcp_server_by_id(
server_id
) or global_mcp_server_manager.get_mcp_server_by_name(server_id)
if (
_server is not None
and _rest_client_ip is not None
and not global_mcp_server_manager._is_server_accessible_from_ip(
_server, _rest_client_ip
)
):
raise HTTPException(
status_code=403,
detail={
"error": "ip_filtering",
"message": (
f"MCP server '{server_id}' is not accessible from your IP address "
f"({_rest_client_ip}). This server is restricted to internal "
"networks only. To make it externally accessible, set "
"'available_on_public_internet: true' in the server configuration."
),
},
)
if _server is None:
raise HTTPException(
status_code=404,
detail={
"error": "server_not_found",
"message": f"MCP server '{server_id}' was not found",
},
)
raise HTTPException(
status_code=403,
detail={
@@ -285,7 +350,7 @@ if MCP_AVAILABLE:
if server is not None:
allowed_mcp_servers.append(server)
return allowed_mcp_servers
return allowed_mcp_servers, canonical_server_id
async def _get_tools_for_single_server(
server,
@@ -301,6 +366,7 @@ if MCP_AVAILABLE:
extra_headers=extra_headers,
add_prefix=False,
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
)
# Filter tools based on allowed_tools configuration
@@ -753,7 +819,7 @@ if MCP_AVAILABLE:
},
)
tool_arguments = data.get("arguments")
tool_arguments = data.get("arguments") or {}
proxy_base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data)
(
@@ -786,14 +852,18 @@ if MCP_AVAILABLE:
data["user_api_key_auth"] = data["metadata"]["user_api_key_auth"]
# Resolve allowed MCP servers with IP filtering
allowed_mcp_servers = await _resolve_allowed_mcp_servers_with_ip_filter(
(
allowed_mcp_servers,
canonical_server_id,
) = await _resolve_allowed_mcp_servers_with_ip_filter(
request, user_api_key_dict, server_id
)
# Look up per-user OAuth headers for this server (mirrors list_tool_rest_api).
user_oauth_extra_headers: Optional[Dict[str, str]] = None
target_server = next(
(s for s in allowed_mcp_servers if s.server_id == server_id), None
(s for s in allowed_mcp_servers if s.server_id == canonical_server_id),
None,
)
if target_server is not None:
user_oauth_extra_headers = await _get_user_oauth_extra_headers(
@@ -812,6 +882,7 @@ if MCP_AVAILABLE:
oauth2_headers=user_oauth_extra_headers or data.get("oauth2_headers"),
raw_headers=data.get("raw_headers"),
litellm_logging_obj=data.get("litellm_logging_obj"),
requested_server_id=canonical_server_id,
)
return result
except BlockedPiiEntityError as e:
@@ -1368,6 +1368,7 @@ if MCP_AVAILABLE:
extra_headers=extra_headers,
add_prefix=True, # Always add server prefix
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
)
filtered_tools = filter_tools_by_allowed_tools(tools, server)
@@ -2074,6 +2075,7 @@ if MCP_AVAILABLE:
"""
# Track resolved MCP server for both permission checks and dispatch
mcp_server: Optional[MCPServer] = None
requested_server_id: Optional[str] = kwargs.get("requested_server_id")
# If the client called with a display-name override (e.g. "Get Pet"),
# translate it back to the original prefixed name before any routing.
@@ -2082,14 +2084,55 @@ if MCP_AVAILABLE:
# Remove prefix from tool name for logging and processing
original_tool_name, server_name = split_server_prefix_from_name(name)
requested_server: Optional[MCPServer] = None
if requested_server_id:
requested_server = next(
(s for s in allowed_mcp_servers if s.server_id == requested_server_id),
None,
)
# Resolve the actual MCP server up-front so the permission check uses
# the canonical server.name even when the tool name is prefixed with a
# short ID (LITELLM_USE_SHORT_MCP_TOOL_PREFIX) that doesn't match the
# server's display name directly.
mcp_server = global_mcp_server_manager._get_mcp_server_from_tool_name(name)
if mcp_server is None and requested_server is not None:
# REST callers may pass the raw tool name (no prefix) plus a
# ``requested_server_id``. The mapping might only contain the
# prefixed form, so retry the lookup with every known prefix of
# the requested server before treating the tool as unresolved —
# otherwise the tool_server_mismatch guard below is silently
# bypassed.
for known_prefix in iter_known_server_prefixes(requested_server):
candidate = global_mcp_server_manager._get_mcp_server_from_tool_name(
add_server_prefix_to_name(name, known_prefix)
)
if candidate is not None:
mcp_server = candidate
break
if mcp_server is not None:
server_name = mcp_server.name
# REST /mcp-rest/tools/call passes server_id — tool must belong to that server
if requested_server is not None:
if (
mcp_server is not None
and mcp_server.server_id != requested_server.server_id
):
raise HTTPException(
status_code=403,
detail={
"error": "tool_server_mismatch",
"message": (
f"Tool '{name}' belongs to MCP server '{mcp_server.name}' "
f"but request specified server_id for '{requested_server.name}'."
),
},
)
if mcp_server is None:
mcp_server = requested_server
server_name = requested_server.name
# Only enforce server-level permissions when we can resolve a server
if server_name:
if not MCPRequestHandler.is_tool_allowed(
@@ -92,6 +92,8 @@ from litellm.types.utils import CallTypesLiteral
# Module-level singleton for the JWKS discovery endpoint to access.
_mcp_jwt_signer_instance: Optional["MCPJWTSigner"] = None
_MCP_JWT_CALL_TYPES = frozenset({"call_mcp_tool", "list_mcp_tools"})
# Simple in-memory JWKS cache: keyed by JWKS URI → (keys_list, fetched_at).
_jwks_cache: Dict[str, tuple] = {}
_JWKS_CACHE_TTL = 3600 # 1 hour
@@ -603,17 +605,23 @@ class MCPJWTSigner(CustomGuardrail):
# FR-10: Scope building
# ------------------------------------------------------------------
def _build_scope(self, raw_tool_name: str) -> str:
def _build_scope(
self,
raw_tool_name: str,
call_type: Optional[CallTypesLiteral] = None,
) -> str:
"""
Build the JWT scope string.
When allowed_scopes is configured: join them verbatim.
Otherwise auto-generate minimal, least-privilege scopes:
- Tool call mcp:tools/call mcp:tools/<name>:call
- No tool mcp:tools/call mcp:tools/list
- No tool mcp:tools/list
NOTE: tools/list is intentionally NOT granted on tool-call JWTs to
prevent callers from enumerating tools they didn't ask to use.
Conversely, tools/call is NOT granted on tools/list-only JWTs so an
intercepted list token cannot be replayed to invoke tools.
"""
if self.allowed_scopes is not None:
return " ".join(self.allowed_scopes)
@@ -623,8 +631,14 @@ class MCPJWTSigner(CustomGuardrail):
)
if tool_name:
scopes = ["mcp:tools/call", f"mcp:tools/{tool_name}:call"]
elif call_type == "call_mcp_tool":
# Tool-call request reached the signer without a tool name (e.g.
# missing mcp_tool_name in hook data). Fall back to a generic
# tools/call scope so the upstream server still accepts the
# invocation rather than rejecting it as a tools/list-only token.
scopes = ["mcp:tools/call"]
else:
scopes = ["mcp:tools/call", "mcp:tools/list"]
scopes = ["mcp:tools/list"]
return " ".join(scopes)
# ------------------------------------------------------------------
@@ -673,6 +687,7 @@ class MCPJWTSigner(CustomGuardrail):
user_api_key_dict: UserAPIKeyAuth,
data: dict,
jwt_claims: Optional[Dict[str, Any]] = None,
call_type: Optional[CallTypesLiteral] = None,
) -> Dict[str, Any]:
"""
Build JWT claims for the outbound MCP access token.
@@ -713,7 +728,7 @@ class MCPJWTSigner(CustomGuardrail):
# scope (FR-10)
raw_tool_name: str = data.get("mcp_tool_name", "")
claims["scope"] = self._build_scope(raw_tool_name)
claims["scope"] = self._build_scope(raw_tool_name, call_type=call_type)
# optional_claims passthrough (FR-15)
claims = self._passthrough_optional_claims(claims, jwt_claims)
@@ -779,16 +794,20 @@ class MCPJWTSigner(CustomGuardrail):
Verifies the incoming token (when configured), validates required claims,
then signs an outbound JWT and injects it as the Authorization header.
All non-MCP call types pass through unchanged.
Signs outbound MCP tool calls and tools/list requests.
"""
if call_type != "call_mcp_tool":
if call_type not in _MCP_JWT_CALL_TYPES:
return data
hook_data = dict(data)
if call_type == "list_mcp_tools":
hook_data["mcp_tool_name"] = ""
# ------------------------------------------------------------------
# FR-5: Verify incoming token before re-signing
# ------------------------------------------------------------------
jwt_claims: Optional[Dict[str, Any]] = None
raw_token: Optional[str] = data.get("incoming_bearer_token")
raw_token: Optional[str] = hook_data.get("incoming_bearer_token")
if self.access_token_discovery_uri and raw_token:
# Three-dot pattern → JWT; otherwise opaque.
@@ -837,7 +856,9 @@ class MCPJWTSigner(CustomGuardrail):
# ------------------------------------------------------------------
# Build outbound access token
# ------------------------------------------------------------------
claims = self._build_claims(user_api_key_dict, data, jwt_claims)
claims = self._build_claims(
user_api_key_dict, hook_data, jwt_claims, call_type=call_type
)
signed_token = jwt.encode(
claims,
@@ -848,7 +869,7 @@ class MCPJWTSigner(CustomGuardrail):
# Merge into existing extra_headers — a prior guardrail in the chain may
# have already injected tracing headers or correlation IDs.
existing_headers: Dict[str, str] = data.get("extra_headers") or {}
existing_headers: Dict[str, str] = hook_data.get("extra_headers") or {}
new_headers: Dict[str, str] = {
**existing_headers,
"Authorization": f"Bearer {signed_token}",
@@ -875,17 +896,74 @@ class MCPJWTSigner(CustomGuardrail):
claims, self._kid
)
data["extra_headers"] = new_headers
hook_data["extra_headers"] = new_headers
verbose_proxy_logger.debug(
"MCPJWTSigner: signed JWT sub=%s act=%s tool=%s exp=%d "
"verified=%s channel=%s",
"verified=%s channel=%s call_type=%s",
claims.get("sub"),
claims.get("act", {}).get("sub"),
data.get("mcp_tool_name"),
hook_data.get("mcp_tool_name"),
claims["exp"],
jwt_claims is not None,
bool(self.channel_token_audience),
call_type,
)
return data
return hook_data
async def inject_mcp_jwt_headers_for_upstream(
user_api_key_dict: Optional[UserAPIKeyAuth],
extra_headers: Optional[Dict[str, str]] = None,
raw_headers: Optional[Dict[str, str]] = None,
*,
for_list_tools: bool = False,
mcp_tool_name: str = "",
) -> Dict[str, str]:
"""
Sign outbound MCP headers when MCPJWTSigner is configured.
Used by tools/list paths that do not go through proxy pre_call_hook.
"""
merged = dict(extra_headers or {})
signer = get_mcp_jwt_signer()
if signer is None or user_api_key_dict is None:
return merged
normalized_raw = {k.lower(): v for k, v in (raw_headers or {}).items()}
incoming_bearer_token: Optional[str] = None
auth_hdr = normalized_raw.get("authorization", "")
if auth_hdr.lower().startswith("bearer "):
incoming_bearer_token = auth_hdr[len("bearer ") :]
hook_data: Dict[str, Any] = {
"mcp_tool_name": "" if for_list_tools else mcp_tool_name,
"incoming_bearer_token": incoming_bearer_token,
"extra_headers": merged,
}
call_type: CallTypesLiteral = (
"list_mcp_tools" if for_list_tools else "call_mcp_tool"
)
try:
from litellm.proxy.proxy_server import ( # noqa: PLC0415
proxy_logging_obj as _proxy_logging,
)
shared_cache = (
_proxy_logging.internal_usage_cache.dual_cache
if _proxy_logging is not None
else DualCache()
)
except Exception:
shared_cache = DualCache()
result = await signer.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=shared_cache,
data=hook_data,
call_type=call_type,
)
if isinstance(result, dict) and result.get("extra_headers"):
merged.update(result["extra_headers"])
return merged
+7
View File
@@ -382,6 +382,11 @@ async def test_mcp_http_transport_tool_not_found():
}
)
# Mapping populated for this server but not for the requested tool
test_manager.tool_name_to_mcp_server_name_mapping["gmail_send_email"] = (
"test_http_server"
)
# Try to call a tool that doesn't exist in mapping
with pytest.raises(ValueError, match="Tool nonexistent_tool not found"):
await test_manager.call_tool(
@@ -881,6 +886,7 @@ async def test_get_tools_from_mcp_servers():
extra_headers=None,
add_prefix=False,
raw_headers=None,
user_api_key_auth=None,
):
if server.server_id == "server1_id":
return [mock_tool_1]
@@ -1856,6 +1862,7 @@ async def test_get_tools_for_single_server():
extra_headers=None,
add_prefix=False,
raw_headers=None,
user_api_key_auth=None,
)
# Verify the result
@@ -774,6 +774,7 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails():
extra_headers=None,
add_prefix=True,
raw_headers=None,
user_api_key_auth=None,
):
if server.name == "working_server":
# Working server returns tools
@@ -879,6 +880,7 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing():
extra_headers=None,
add_prefix=True,
raw_headers=None,
user_api_key_auth=None,
):
# All servers fail
raise Exception(f"Server {server.name} connection failed")
@@ -1339,6 +1341,7 @@ async def test_list_tools_single_server_unprefixed_names():
extra_headers=None,
add_prefix=False,
raw_headers=None,
user_api_key_auth=None,
):
tool = MagicMock()
tool.name = f"{server.alias}-toolA" if add_prefix else "toolA"
@@ -1420,6 +1423,7 @@ async def test_list_tools_multiple_servers_prefixed_names():
extra_headers=None,
add_prefix=True,
raw_headers=None,
user_api_key_auth=None,
):
tool = MagicMock()
# When multiple servers, add_prefix should be True -> prefixed names
@@ -1686,6 +1690,7 @@ async def test_list_tools_filters_by_key_team_permissions():
extra_headers=None,
add_prefix=False,
raw_headers=None,
user_api_key_auth=None,
):
# Return 4 tools, but only 2 should be allowed
tool1 = MagicMock()
@@ -1795,6 +1800,7 @@ async def test_list_tools_with_team_tool_permissions_inheritance():
extra_headers=None,
add_prefix=False,
raw_headers=None,
user_api_key_auth=None,
):
# Return 4 tools
tool1 = MagicMock()
@@ -1890,6 +1896,7 @@ async def test_list_tools_with_no_tool_permissions_shows_all():
extra_headers=None,
add_prefix=False,
raw_headers=None,
user_api_key_auth=None,
):
# Return 3 tools
tool1 = MagicMock()
@@ -1988,6 +1995,7 @@ async def test_list_tools_strips_prefix_when_matching_permissions():
extra_headers=None,
add_prefix=True,
raw_headers=None,
user_api_key_auth=None,
):
# Return tools WITH prefix (as they come from MCP server)
tool1 = MagicMock()
@@ -322,6 +322,7 @@ class TestMCPServerManager:
mcp_auth_header=None,
mcp_protocol_version=None,
raw_headers=None,
user_api_key_auth=None,
):
if server.name == "github":
tool1 = MagicMock()
@@ -376,6 +377,7 @@ class TestMCPServerManager:
mcp_auth_header=None,
mcp_protocol_version=None,
raw_headers=None,
user_api_key_auth=None,
):
assert mcp_auth_header == "legacy-token" # Should use legacy header
tool = MagicMock()
@@ -414,6 +416,7 @@ class TestMCPServerManager:
mcp_auth_header=None,
mcp_protocol_version=None,
raw_headers=None,
user_api_key_auth=None,
):
assert (
mcp_auth_header == "server-specific-token"
@@ -1004,6 +1007,7 @@ class TestMCPServerManager:
mcp_auth_header=None,
mcp_protocol_version=None,
raw_headers=None,
user_api_key_auth=None,
):
assert (
mcp_auth_header == "server-specific-token"
@@ -1801,6 +1805,258 @@ class TestMCPServerManager:
assert len(tools_unprefixed) == 1
assert tools_unprefixed[0].name == "send_email"
@pytest.mark.asyncio
async def test_get_tools_from_server_jwt_skipped_when_mcp_auth_header_set(self):
"""When a per-user mcp_auth_header is resolved, JWT injection must be skipped.
MCPClient._get_auth_headers() applies extra_headers AFTER writing
Authorization from auth_value, so an injected JWT would clobber the
user's per-server OAuth token. Regression test for that interaction.
"""
from litellm.proxy._types import UserAPIKeyAuth
manager = MCPServerManager()
server = MCPServer(
server_id="zapier",
name="zapier",
transport=MCPTransport.http,
)
manager._create_mcp_client = AsyncMock(return_value=object())
manager._fetch_tools_with_timeout = AsyncMock(return_value=[])
user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice")
with (
patch(
"litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer.get_mcp_jwt_signer",
return_value=MagicMock(),
),
patch(
"litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer.inject_mcp_jwt_headers_for_upstream",
new=AsyncMock(return_value={"Authorization": "Bearer signed-jwt"}),
) as mock_inject,
):
# Case A: mcp_auth_header present -> JWT must NOT be injected
await manager._get_tools_from_server(
server,
mcp_auth_header="oauth-user-token",
user_api_key_auth=user_auth,
)
mock_inject.assert_not_called()
# Case B: no mcp_auth_header -> JWT injection runs as before
await manager._get_tools_from_server(
server,
user_api_key_auth=user_auth,
)
mock_inject.assert_awaited_once()
def test_resolve_mcp_server_for_tool_call_via_prefixed_name(self):
"""Resolution succeeds when the prefixed tool name is in the mapping."""
manager = MCPServerManager()
server = MCPServer(
server_id="jira",
name="jira",
transport=MCPTransport.http,
)
manager.registry = {"jira": server}
manager.tool_name_to_mcp_server_name_mapping["jira-search_issues"] = "jira"
manager.tool_name_to_mcp_server_name_mapping["search_issues"] = "jira"
resolved = manager._resolve_mcp_server_for_tool_call("jira", "search_issues")
assert resolved is server
def test_resolve_mcp_server_for_tool_call_via_alias(self):
"""Resolution falls back to alias/server_name match in the registry."""
manager = MCPServerManager()
server = MCPServer(
server_id="srv-uuid-123",
name="zapier",
alias="zapier-alias",
transport=MCPTransport.http,
)
manager.registry = {"srv-uuid-123": server}
manager.tool_name_to_mcp_server_name_mapping["create_zap"] = "zapier"
resolved = manager._resolve_mcp_server_for_tool_call(
"zapier-alias", "create_zap"
)
assert resolved is server
def test_resolve_mcp_server_for_tool_call_unknown_tool_with_empty_mapping(self):
"""Server-name match alone must not let unknown tools through when the
mapping has no entries for that server (e.g. listing has not completed
or the server is OAuth2 and the user has not yet listed tools).
"""
manager = MCPServerManager()
server = MCPServer(
server_id="srv-uuid-123",
name="zapier",
alias="zapier-alias",
transport=MCPTransport.http,
)
manager.registry = {"srv-uuid-123": server}
with pytest.raises(ValueError, match="Tool create_zap not found"):
manager._resolve_mcp_server_for_tool_call("zapier-alias", "create_zap")
def test_resolve_mcp_server_for_tool_call_fallback_to_unprefixed_lookup(self):
"""Fallback to unprefixed _get_mcp_server_from_tool_name when other paths fail."""
manager = MCPServerManager()
server = MCPServer(
server_id="linear",
name="linear",
transport=MCPTransport.http,
)
manager.registry = {"linear": server}
manager.tool_name_to_mcp_server_name_mapping["create_issue"] = "linear"
# server_name is empty so the fallback unprefixed lookup runs and matches.
resolved = manager._resolve_mcp_server_for_tool_call("", "create_issue")
assert resolved is server
def test_resolve_mcp_server_for_tool_call_raises_when_not_found(self):
"""ValueError is raised when no resolution path finds the tool."""
manager = MCPServerManager()
with pytest.raises(ValueError, match="Tool .* not found"):
manager._resolve_mcp_server_for_tool_call("nonexistent", "ghost_tool")
def test_resolve_mcp_server_for_tool_call_unknown_tool_with_known_server(self):
"""Server-name match alone must not let unknown tools slip through.
If the registry has tools for this server but neither the prefixed nor
unprefixed tool name is in the mapping, raise rather than returning the
server (would otherwise allow tool enumeration via name spoofing).
"""
manager = MCPServerManager()
server = MCPServer(
server_id="github",
name="github",
transport=MCPTransport.http,
)
manager.registry = {"github": server}
# Mapping has *some* tools for github but not "missing_tool".
manager.tool_name_to_mcp_server_name_mapping["github-list_repos"] = "github"
manager.tool_name_to_mcp_server_name_mapping["list_repos"] = "github"
with pytest.raises(ValueError, match="Tool missing_tool not found"):
manager._resolve_mcp_server_for_tool_call("github", "missing_tool")
@pytest.mark.asyncio
async def test_resolve_oauth2_headers_skipped_when_not_user_oauth(self):
"""Returns input headers unchanged when server does not need user OAuth."""
from litellm.proxy._types import UserAPIKeyAuth
manager = MCPServerManager()
server = MCPServer(
server_id="plain",
name="plain",
transport=MCPTransport.http,
)
# needs_user_oauth_token defaults to False.
user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="bob")
result = await manager._resolve_oauth2_headers_for_tool_call(
server, oauth2_headers=None, user_api_key_auth=user_auth
)
assert result is None
@pytest.mark.asyncio
async def test_resolve_oauth2_headers_returns_client_supplied_token(self):
"""Returns the client's oauth2_headers as-is when already set."""
from litellm.proxy._types import UserAPIKeyAuth
manager = MCPServerManager()
server = MCPServer(
server_id="oauth-srv",
name="oauth-srv",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
)
assert server.needs_user_oauth_token is True
user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice")
supplied = {"Authorization": "Bearer client-supplied"}
result = await manager._resolve_oauth2_headers_for_tool_call(
server, oauth2_headers=supplied, user_api_key_auth=user_auth
)
assert result is supplied
@pytest.mark.asyncio
async def test_resolve_oauth2_headers_looks_up_stored_token(self):
"""Falls back to stored per-user OAuth headers when no token is supplied."""
from litellm.proxy._types import UserAPIKeyAuth
manager = MCPServerManager()
server = MCPServer(
server_id="oauth-srv",
name="oauth-srv",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
)
user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice")
stored = {"Authorization": "Bearer stored-user-token"}
with patch(
"litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db",
new=AsyncMock(return_value=stored),
) as mock_lookup:
result = await manager._resolve_oauth2_headers_for_tool_call(
server, oauth2_headers=None, user_api_key_auth=user_auth
)
assert result == stored
mock_lookup.assert_awaited_once()
@pytest.mark.asyncio
async def test_resolve_oauth2_headers_swallows_lookup_exception(self):
"""Returns supplied headers (None) when the stored-token lookup raises."""
from litellm.proxy._types import UserAPIKeyAuth
manager = MCPServerManager()
server = MCPServer(
server_id="oauth-srv",
name="oauth-srv",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
)
user_auth = UserAPIKeyAuth(api_key="sk-test", user_id="alice")
with patch(
"litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db",
new=AsyncMock(side_effect=RuntimeError("redis down")),
):
result = await manager._resolve_oauth2_headers_for_tool_call(
server, oauth2_headers=None, user_api_key_auth=user_auth
)
assert result is None
@pytest.mark.asyncio
async def test_resolve_oauth2_headers_no_user_id(self):
"""Skip lookup entirely when user_api_key_auth has no user_id."""
from litellm.proxy._types import UserAPIKeyAuth
manager = MCPServerManager()
server = MCPServer(
server_id="oauth-srv",
name="oauth-srv",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
)
# user_id is None -> lookup must not happen
user_auth = UserAPIKeyAuth(api_key="sk-test")
with patch(
"litellm.proxy._experimental.mcp_server.server._get_user_oauth_extra_headers_from_db",
new=AsyncMock(return_value={"Authorization": "Bearer x"}),
) as mock_lookup:
result = await manager._resolve_oauth2_headers_for_tool_call(
server, oauth2_headers=None, user_api_key_auth=user_auth
)
assert result is None
mock_lookup.assert_not_called()
def test_create_prefixed_tools_updates_mapping_for_both_forms(self):
"""_create_prefixed_tools should populate mapping for prefixed and original names even when not adding prefix in output."""
manager = MCPServerManager()
@@ -1,5 +1,6 @@
import json
from typing import Any, Dict, Optional
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
@@ -796,6 +797,25 @@ class TestCallToolRestAPI:
raising=False,
)
mock_server = MagicMock()
mock_server.server_id = "server-1"
def fake_get_mcp_server_by_id(server_id):
return mock_server if server_id == "server-1" else None
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_mcp_server_by_id",
fake_get_mcp_server_by_id,
raising=False,
)
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_mcp_server_by_name",
lambda *args, **kwargs: None,
raising=False,
)
request_payload = {
"server_id": "server-1",
"name": "demo-tool",
@@ -219,7 +219,7 @@ def test_build_claims_scope_with_tool():
def test_build_claims_scope_without_tool():
"""_build_claims() includes mcp:tools/list when no specific tool is called."""
"""_build_claims() emits only mcp:tools/list when no specific tool is called."""
signer = _make_signer()
user_dict = _make_user_api_key_dict()
data: Dict[str, Any] = {}
@@ -227,10 +227,11 @@ def test_build_claims_scope_without_tool():
claims = signer._build_claims(user_dict, data)
scopes = set(claims["scope"].split())
assert "mcp:tools/call" in scopes
assert "mcp:tools/list" in scopes
# List-only JWTs must NOT carry mcp:tools/call — least-privilege
assert "mcp:tools/call" not in scopes
# No per-tool call scope when no tool name was given
assert not any(s.endswith(":call") and s != "mcp:tools/call" for s in scopes)
assert not any(s.endswith(":call") for s in scopes)
def test_build_claims_act_fallback_to_litellm_proxy():
@@ -338,7 +339,7 @@ async def test_hook_skips_non_mcp_call_types():
user_dict = _make_user_api_key_dict()
data = {"messages": [{"role": "user", "content": "hello"}]}
for call_type in ("completion", "acompletion", "embedding", "list_mcp_tools"):
for call_type in ("completion", "acompletion", "embedding"):
original_data = {**data}
result = await signer.async_pre_call_hook(
user_api_key_dict=user_dict,
@@ -351,6 +352,33 @@ async def test_hook_skips_non_mcp_call_types():
), f"extra_headers should not be set for {call_type}"
@pytest.mark.asyncio
async def test_hook_signs_list_mcp_tools():
"""async_pre_call_hook() signs JWT for list_mcp_tools with list scope."""
signer = _make_signer(
issuer="https://litellm.example.com", audience="mcp", ttl_seconds=300
)
user_dict = _make_user_api_key_dict(user_id="alice", team_id="backend")
data = {"mcp_tool_name": "should_be_cleared"}
result = await signer.async_pre_call_hook(
user_api_key_dict=user_dict,
cache=MagicMock(),
data=data,
call_type="list_mcp_tools",
)
assert isinstance(result, dict)
assert "extra_headers" in result
assert result["extra_headers"]["Authorization"].startswith("Bearer ")
token = result["extra_headers"]["Authorization"].removeprefix("Bearer ")
decoded = _decode_unverified(token)
scopes = set(decoded["scope"].split())
assert "mcp:tools/list" in scopes
# List-only JWTs must NOT carry mcp:tools/call — least-privilege
assert "mcp:tools/call" not in scopes
@pytest.mark.asyncio
async def test_signed_token_is_verifiable():
"""The JWT injected by the hook can be verified against the JWKS public key."""
@@ -1128,3 +1156,116 @@ async def test_hook_raises_401_when_jwt_verification_fails():
)
assert exc_info.value.status_code == 401
# --- _build_scope branches: call_mcp_tool with empty tool name, list_mcp_tools ---
def test_build_scope_call_type_call_mcp_tool_without_tool_name():
"""call_mcp_tool with empty tool name emits a generic mcp:tools/call only."""
signer = _make_signer()
scope = signer._build_scope("", call_type="call_mcp_tool")
scopes = set(scope.split())
assert scopes == {"mcp:tools/call"}
def test_build_scope_call_type_list_mcp_tools_only_list():
"""list_mcp_tools (no tool) emits only mcp:tools/list, never tools/call."""
signer = _make_signer()
scope = signer._build_scope("", call_type="list_mcp_tools")
scopes = set(scope.split())
assert scopes == {"mcp:tools/list"}
def test_build_scope_default_is_list_only_when_no_call_type():
"""No call_type and no tool falls through to tools/list (least-privilege default)."""
signer = _make_signer()
scope = signer._build_scope("")
scopes = set(scope.split())
assert "mcp:tools/list" in scopes
assert "mcp:tools/call" not in scopes
# --- inject_mcp_jwt_headers_for_upstream ---
@pytest.mark.asyncio
async def test_inject_mcp_jwt_returns_unchanged_when_signer_not_configured():
"""No signer configured -> return a fresh copy of extra_headers untouched."""
import litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer as mod
from litellm.proxy._types import UserAPIKeyAuth
mod._mcp_jwt_signer_instance = None
headers = {"X-Trace-Id": "abc"}
user_dict = UserAPIKeyAuth(api_key="sk-test", user_id="alice")
result = await mod.inject_mcp_jwt_headers_for_upstream(
user_api_key_dict=user_dict,
extra_headers=headers,
)
assert result == headers
assert result is not headers # must be a copy
@pytest.mark.asyncio
async def test_inject_mcp_jwt_returns_unchanged_when_user_dict_none():
"""No user_api_key_dict -> short-circuit without invoking the signer."""
from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
inject_mcp_jwt_headers_for_upstream,
)
_make_signer() # ensure instance is created
result = await inject_mcp_jwt_headers_for_upstream(
user_api_key_dict=None,
extra_headers={"X-Trace-Id": "abc"},
)
assert result == {"X-Trace-Id": "abc"}
@pytest.mark.asyncio
async def test_inject_mcp_jwt_signs_for_list_tools_path():
"""When for_list_tools=True, signer is invoked with list_mcp_tools call_type."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
inject_mcp_jwt_headers_for_upstream,
)
_make_signer(issuer="https://litellm.example.com", audience="mcp", ttl_seconds=300)
user_dict = UserAPIKeyAuth(api_key="sk-test", user_id="alice")
result = await inject_mcp_jwt_headers_for_upstream(
user_api_key_dict=user_dict,
extra_headers={"X-Trace": "1"},
raw_headers={"Authorization": "Bearer incoming.opaque.token"},
for_list_tools=True,
)
assert result["X-Trace"] == "1"
assert result["Authorization"].startswith("Bearer ")
token = result["Authorization"].removeprefix("Bearer ")
decoded = _decode_unverified(token)
scopes = set(decoded["scope"].split())
assert scopes == {"mcp:tools/list"}
@pytest.mark.asyncio
async def test_inject_mcp_jwt_signs_for_tool_call_path():
"""for_list_tools=False with a tool name signs a call_mcp_tool JWT."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
inject_mcp_jwt_headers_for_upstream,
)
_make_signer(issuer="https://litellm.example.com", audience="mcp", ttl_seconds=300)
user_dict = UserAPIKeyAuth(api_key="sk-test", user_id="alice")
result = await inject_mcp_jwt_headers_for_upstream(
user_api_key_dict=user_dict,
for_list_tools=False,
mcp_tool_name="search_web",
)
assert result["Authorization"].startswith("Bearer ")
token = result["Authorization"].removeprefix("Bearer ")
decoded = _decode_unverified(token)
scopes = set(decoded["scope"].split())
assert "mcp:tools/call" in scopes
assert "mcp:tools/search_web:call" in scopes