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:
Devin AI 2026-07-15 04:27:16 +00:00
parent 18b85f4ba7
commit 80dd2b47a5
3 changed files with 159 additions and 63 deletions

View file

@ -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 (

View file

@ -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)

View file

@ -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"}