From f82e588a50c67fb5144ff86f9f05304a18d6b9ab Mon Sep 17 00:00:00 2001 From: MokshaVora Date: Mon, 10 Aug 2026 17:16:18 +0530 Subject: [PATCH] fix(mcp): filter local openapi headers --- .../proxy/_experimental/mcp_server/server.py | 30 ++------ .../mcp_server/test_mcp_server.py | 69 +++++++++++++++++++ 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index bacab883ace..92a971ac758 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -429,6 +429,7 @@ if MCP_AVAILABLE: _caller_authorization_fans_out, _client_forwarded_authorization_headers, _is_gateway_attribution_header, + _openapi_forwarded_extra_headers, _should_strip_caller_authorization, _without_authorization, global_mcp_server_manager, @@ -2854,30 +2855,11 @@ if MCP_AVAILABLE: 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: dict[str, str] | None = None - if mcp_server and mcp_server.extra_headers and raw_headers: - normalized_raw: Final = {str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str)} - skip_caller_authorization: Final = _should_strip_caller_authorization( - mcp_server=mcp_server, - 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 + forwarded_headers: Final = _openapi_forwarded_extra_headers( + mcp_server=mcp_server, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) resolved_auth_headers: dict[str, str] | None = None if mcp_server: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index ecf68730dfd..e17b356fc39 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -6675,6 +6675,75 @@ async def test_execute_mcp_tool_sets_model_in_model_call_details(): assert litellm_logging_obj.model == "MCP: list_pets" +@pytest.mark.asyncio +async def test_execute_mcp_tool_local_openapi_does_not_forward_gateway_attribution_headers(): + from litellm.proxy._experimental.mcp_server import server as mcp_module + from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + _request_extra_headers, + ) + + server = MCPServer( + server_id="local-openapi-server", + name="local_openapi", + server_name="local_openapi", + url="https://example.com", + transport=MCPTransport.http, + spec_path="/specs/openapi.yaml", + extra_headers=["x-request-id", "X-LiteLLM-Tags", "X-LiteLLM-End-User-Id"], + ) + captured_extra_headers = None + + async def capture_local_tool(*args, **kwargs): + nonlocal captured_extra_headers + captured_extra_headers = _request_extra_headers.get() + return [] + + with ( + patch.object( + mcp_module.global_mcp_server_manager, + "_get_mcp_server_from_tool_name", + return_value=server, + ), + patch.object( + mcp_module.global_mcp_server_manager, + "pre_call_tool_check", + new=AsyncMock(return_value={}), + ), + patch.object( + mcp_module.global_mcp_server_manager, + "resolve_openapi_upstream_auth", + new=AsyncMock(side_effect=lambda **kwargs: (None, kwargs["forwarded_headers"])), + ), + patch.object( + mcp_module.global_mcp_tool_registry, + "get_tool", + return_value=MagicMock(), + ), + patch( + "litellm.proxy._experimental.mcp_server.server._handle_local_mcp_tool", + new=AsyncMock(side_effect=capture_local_tool), + ), + patch.object( + mcp_module.MCPRequestHandler, + "is_tool_allowed", + return_value=True, + ), + ): + await mcp_module.execute_mcp_tool( + name="local_openapi-list_pets", + arguments={}, + allowed_mcp_servers=[server], + start_time=datetime.now(), + raw_headers={ + "x-request-id": "request-123", + "x-litellm-tags": "application:orders,service:checkout", + "x-litellm-end-user-id": "user-123", + }, + ) + + assert captured_extra_headers == {"x-request-id": "request-123"} + + @pytest.mark.asyncio async def test_execute_mcp_tool_rest_unresolved_prefixed_name_routes_to_requested_server(): """A prefixed REST name that resolves to no tool must still dispatch to the server_id.