From 80dd2b47a5b7d9604912257ab1a01499d8511c42 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:27:16 +0000 Subject: [PATCH] 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. --- .../mcp_server/mcp_server_manager.py | 55 ++++++++++- .../proxy/_experimental/mcp_server/server.py | 68 ++----------- .../mcp_server/test_mcp_server_manager.py | 99 +++++++++++++++++++ 3 files changed, 159 insertions(+), 63 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index e6e265abb61..9d6248d2acd 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -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 ( diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 865f148e044..e3155fb32a3 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -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) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index adcfff6fe9d..0757bf21d59 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -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"}