mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(mcp): forward per-server auth header on MCP-protocol OpenAPI path
The MCP-protocol dispatch (MCPServerManager.call_tool) had the same gap as
the REST path: OpenAPI-backed tools resolved the upstream auth only from the
global/BYOK mcp_auth_header and ignored the per-server
x-mcp-{alias}-authorization header in mcp_server_auth_headers. Extract a shared
_resolve_openapi_tool_auth helper and use it in both paths so they cannot drift.
This commit is contained in:
parent
18b85f4ba7
commit
80dd2b47a5
3 changed files with 159 additions and 63 deletions
|
|
@ -101,6 +101,7 @@ from litellm.proxy._experimental.mcp_server.utils import (
|
|||
is_short_mcp_tool_prefix_enabled,
|
||||
is_tool_name_prefixed,
|
||||
iter_known_server_prefixes,
|
||||
lookup_mcp_server_auth_in_headers,
|
||||
merge_mcp_headers,
|
||||
normalize_server_name,
|
||||
parse_admin_env_vars,
|
||||
|
|
@ -341,6 +342,51 @@ def _openapi_forwarded_extra_headers(
|
|||
return forwarded or None
|
||||
|
||||
|
||||
def _resolve_openapi_tool_auth(
|
||||
mcp_server: MCPServer,
|
||||
mcp_auth_header: Optional[str],
|
||||
mcp_server_auth_headers: Optional[dict[str, dict[str, str]]],
|
||||
raw_headers: Optional[dict[str, str]],
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
) -> tuple[Optional[str], Optional[dict[str, str]]]:
|
||||
"""Resolve the ``Authorization`` value and forwarded extra headers for an
|
||||
OpenAPI-backed MCP tool call.
|
||||
|
||||
A per-server ``x-mcp-{alias}-authorization`` header (carried in
|
||||
``mcp_server_auth_headers``) takes precedence over the deprecated global /
|
||||
BYOK ``mcp_auth_header``, matching how ``_call_regular_mcp_tool`` resolves the
|
||||
upstream credential for managed servers. Per-server header values are already
|
||||
full header values and are forwarded verbatim; a raw BYOK credential is
|
||||
formatted with the server's auth-type prefix.
|
||||
"""
|
||||
forwarded = _openapi_forwarded_extra_headers(mcp_server, raw_headers, user_api_key_auth)
|
||||
|
||||
per_server_auth_header = (
|
||||
lookup_mcp_server_auth_in_headers(
|
||||
mcp_server_auth_headers,
|
||||
alias=mcp_server.alias,
|
||||
server_name=mcp_server.server_name,
|
||||
)
|
||||
if mcp_server_auth_headers
|
||||
else None
|
||||
)
|
||||
|
||||
if isinstance(per_server_auth_header, dict):
|
||||
auth_value: Optional[str] = None
|
||||
extra = dict(forwarded) if forwarded else {}
|
||||
for header_key, header_val in per_server_auth_header.items():
|
||||
if header_key.lower() == "authorization":
|
||||
auth_value = header_val
|
||||
else:
|
||||
extra[header_key] = header_val
|
||||
return auth_value, (extra or None)
|
||||
if isinstance(per_server_auth_header, str) and per_server_auth_header:
|
||||
return per_server_auth_header, forwarded
|
||||
if mcp_auth_header:
|
||||
return _format_byok_openapi_auth_header(mcp_server, mcp_auth_header), forwarded
|
||||
return None, forwarded
|
||||
|
||||
|
||||
async def _resolve_byok_mcp_auth_header(
|
||||
mcp_server: MCPServer,
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
|
|
@ -4407,10 +4453,13 @@ class MCPServerManager:
|
|||
server_name,
|
||||
)
|
||||
|
||||
auth_header_value = (
|
||||
_format_byok_openapi_auth_header(mcp_server, mcp_auth_header) if mcp_auth_header else None
|
||||
auth_header_value, forwarded_headers = _resolve_openapi_tool_auth(
|
||||
mcp_server=mcp_server,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
raw_headers=raw_headers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
)
|
||||
forwarded_headers = _openapi_forwarded_extra_headers(mcp_server, raw_headers, user_api_key_auth)
|
||||
|
||||
async def _call_openapi_via_handler():
|
||||
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
|
||||
|
|
|
|||
|
|
@ -360,6 +360,7 @@ if MCP_AVAILABLE:
|
|||
MCPServerManager,
|
||||
_caller_authorization_fans_out,
|
||||
_client_forwarded_authorization_headers,
|
||||
_resolve_openapi_tool_auth,
|
||||
_should_strip_caller_authorization,
|
||||
_without_authorization,
|
||||
global_mcp_server_manager,
|
||||
|
|
@ -2757,70 +2758,17 @@ if MCP_AVAILABLE:
|
|||
arguments = hook_result["arguments"]
|
||||
|
||||
verbose_logger.debug(f"Executing local registry tool: {name}")
|
||||
per_server_auth_header: Optional[Union[str, dict[str, str]]] = None
|
||||
if mcp_server and mcp_server_auth_headers:
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
lookup_mcp_server_auth_in_headers,
|
||||
)
|
||||
|
||||
per_server_auth_header = lookup_mcp_server_auth_in_headers(
|
||||
mcp_server_auth_headers,
|
||||
alias=mcp_server.alias,
|
||||
server_name=mcp_server.server_name,
|
||||
)
|
||||
|
||||
# For BYOK servers the credential must be injected via a ContextVar
|
||||
# because the tool function has headers baked into its closure.
|
||||
# Pre-format the full Authorization header value using the server's
|
||||
# configured auth_type so the generator doesn't need to know the prefix.
|
||||
auth_header_value: Optional[str] = None
|
||||
per_server_forwarded_headers: Optional[dict[str, str]] = None
|
||||
if isinstance(per_server_auth_header, dict):
|
||||
for header_key, header_val in per_server_auth_header.items():
|
||||
if header_key.lower() == "authorization":
|
||||
auth_header_value = header_val
|
||||
else:
|
||||
if per_server_forwarded_headers is None:
|
||||
per_server_forwarded_headers = {}
|
||||
per_server_forwarded_headers[header_key] = header_val
|
||||
elif isinstance(per_server_auth_header, str) and per_server_auth_header:
|
||||
auth_header_value = per_server_auth_header
|
||||
elif mcp_auth_header:
|
||||
server_auth_type = getattr(mcp_server, "auth_type", None) if mcp_server else None
|
||||
if server_auth_type == MCPAuth.api_key:
|
||||
auth_header_value = f"ApiKey {mcp_auth_header}"
|
||||
elif server_auth_type == MCPAuth.basic:
|
||||
auth_header_value = f"Basic {mcp_auth_header}"
|
||||
else:
|
||||
auth_header_value = f"Bearer {mcp_auth_header}"
|
||||
|
||||
# Forward named client headers to OpenAPI tool upstream requests.
|
||||
# MCPServer.extra_headers lists header names to copy from raw_headers.
|
||||
# The strip decision is centralized in _should_strip_caller_authorization so this
|
||||
# OpenAPI/local path agrees with the managed paths: M2M and the resolver-owned modes
|
||||
# (token_exchange's raw subject token, authorization_code's stored token) must never
|
||||
# have the caller's Authorization forwarded verbatim upstream.
|
||||
forwarded_headers: Optional[Dict[str, str]] = None
|
||||
if mcp_server and mcp_server.extra_headers and raw_headers:
|
||||
normalized_raw = {str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str)}
|
||||
skip_caller_authorization = _should_strip_caller_authorization(
|
||||
if mcp_server is not None:
|
||||
auth_header_value, forwarded_headers = _resolve_openapi_tool_auth(
|
||||
mcp_server=mcp_server,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
raw_headers=raw_headers,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
)
|
||||
for header_name in mcp_server.extra_headers:
|
||||
if not isinstance(header_name, str):
|
||||
continue
|
||||
if skip_caller_authorization and header_name.lower() == "authorization":
|
||||
continue
|
||||
value = normalized_raw.get(header_name.lower())
|
||||
if value is not None:
|
||||
if forwarded_headers is None:
|
||||
forwarded_headers = {}
|
||||
forwarded_headers[header_name] = value
|
||||
|
||||
if per_server_forwarded_headers:
|
||||
forwarded_headers = {**(forwarded_headers or {}), **per_server_forwarded_headers}
|
||||
else:
|
||||
auth_header_value = f"Bearer {mcp_auth_header}" if mcp_auth_header else None
|
||||
forwarded_headers = None
|
||||
|
||||
_auth_token = _request_auth_header.set(auth_header_value)
|
||||
_extra_token = _request_extra_headers.set(forwarded_headers)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
|||
_deserialize_json_dict,
|
||||
_deserialize_json_list,
|
||||
_normalize_mcp_server_cost_info,
|
||||
_resolve_openapi_tool_auth,
|
||||
_should_strip_caller_authorization,
|
||||
_without_authorization,
|
||||
)
|
||||
|
|
@ -7607,3 +7608,101 @@ def test_build_mcp_server_table_carries_null_oauth2_flow():
|
|||
table = manager._build_mcp_server_table(server)
|
||||
|
||||
assert table.oauth2_flow is None
|
||||
|
||||
|
||||
class TestResolveOpenAPIToolAuth:
|
||||
"""Regression for #33344: OpenAPI-backed MCP tools must forward the caller's
|
||||
per-server ``x-mcp-{alias}-authorization`` header (carried in
|
||||
``mcp_server_auth_headers``) to the upstream backend. Pre-fix both OpenAPI
|
||||
dispatch paths resolved the upstream credential only from the deprecated
|
||||
global/BYOK ``mcp_auth_header`` and dropped the per-server credential."""
|
||||
|
||||
def _server(self, **overrides) -> MCPServer:
|
||||
kwargs: Dict[str, Any] = dict(
|
||||
server_id="openapi-srv",
|
||||
name="report_openapi",
|
||||
server_name="report_openapi",
|
||||
alias="report_openapi",
|
||||
url="https://backend.example.com",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.authorization,
|
||||
spec_path="/tmp/spec.json",
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return MCPServer(**kwargs)
|
||||
|
||||
def test_per_server_authorization_forwarded_verbatim(self):
|
||||
auth_value, forwarded = _resolve_openapi_tool_auth(
|
||||
mcp_server=self._server(),
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers={"report_openapi": {"Authorization": "Bearer upstream-token"}},
|
||||
raw_headers=None,
|
||||
user_api_key_auth=None,
|
||||
)
|
||||
assert auth_value == "Bearer upstream-token"
|
||||
assert forwarded is None
|
||||
|
||||
def test_per_server_non_auth_header_forwarded_as_extra(self):
|
||||
auth_value, forwarded = _resolve_openapi_tool_auth(
|
||||
mcp_server=self._server(),
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers={
|
||||
"report_openapi": {"Authorization": "Bearer tok", "X-Tenant-Id": "acme"}
|
||||
},
|
||||
raw_headers=None,
|
||||
user_api_key_auth=None,
|
||||
)
|
||||
assert auth_value == "Bearer tok"
|
||||
assert forwarded == {"X-Tenant-Id": "acme"}
|
||||
|
||||
def test_per_server_takes_precedence_over_byok_auth_header(self):
|
||||
auth_value, _ = _resolve_openapi_tool_auth(
|
||||
mcp_server=self._server(),
|
||||
mcp_auth_header="byok-raw-token",
|
||||
mcp_server_auth_headers={"report_openapi": {"Authorization": "Bearer upstream-token"}},
|
||||
raw_headers=None,
|
||||
user_api_key_auth=None,
|
||||
)
|
||||
assert auth_value == "Bearer upstream-token"
|
||||
|
||||
def test_falls_back_to_byok_auth_header_with_bearer_prefix(self):
|
||||
auth_value, _ = _resolve_openapi_tool_auth(
|
||||
mcp_server=self._server(),
|
||||
mcp_auth_header="byok-raw-token",
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers=None,
|
||||
user_api_key_auth=None,
|
||||
)
|
||||
assert auth_value == "Bearer byok-raw-token"
|
||||
|
||||
def test_falls_back_to_byok_auth_header_with_apikey_prefix(self):
|
||||
auth_value, _ = _resolve_openapi_tool_auth(
|
||||
mcp_server=self._server(auth_type=MCPAuth.api_key),
|
||||
mcp_auth_header="byok-raw-token",
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers=None,
|
||||
user_api_key_auth=None,
|
||||
)
|
||||
assert auth_value == "ApiKey byok-raw-token"
|
||||
|
||||
def test_no_credential_returns_none(self):
|
||||
auth_value, forwarded = _resolve_openapi_tool_auth(
|
||||
mcp_server=self._server(),
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers=None,
|
||||
user_api_key_auth=None,
|
||||
)
|
||||
assert auth_value is None
|
||||
assert forwarded is None
|
||||
|
||||
def test_extra_headers_config_forwards_raw_authorization(self):
|
||||
auth_value, forwarded = _resolve_openapi_tool_auth(
|
||||
mcp_server=self._server(extra_headers=["Authorization"]),
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers={"Authorization": "Bearer caller-token"},
|
||||
user_api_key_auth=None,
|
||||
)
|
||||
assert auth_value is None
|
||||
assert forwarded == {"Authorization": "Bearer caller-token"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue