From 574ea15b8fa72480419e3d4d76c74ad38d920e62 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:42:20 -0700 Subject: [PATCH] fix(mcp): count admin static headers as api_key credential slots An api_key server whose key lives in static_headers, the documented shape for upstreams that expect a custom header name, dispatched fine before the fail-closed check and was rejected as misconfigured after it. The check now treats every static header the admin configured as a credential slot for api_key mode, on both the MCP client path and the OpenAPI tool path, with regression tests at all three layers. --- .../mcp_server/openapi_to_mcp_generator.py | 2 +- .../outbound_credentials/adapter.py | 9 ++++++-- .../outbound_credentials/test_adapter.py | 18 ++++++++++++++- .../mcp_server/test_mcp_server_manager.py | 22 +++++++++++++++++++ .../test_openapi_to_mcp_generator.py | 20 +++++++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 477d86ab436..0cdf40ae8d3 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -512,7 +512,7 @@ def create_tool_function( ) from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error, Ok - match validate_static_credential(auth_type, effective_headers, upstream_token_header): + match validate_static_credential(auth_type, effective_headers, upstream_token_header, headers or ()): case Error(error): raise_public(error) case Ok(): diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index ba223f73b2d..42947e39530 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -13,7 +13,7 @@ from __future__ import annotations import base64 import os -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Final, Literal, NoReturn from fastapi import HTTPException @@ -426,16 +426,19 @@ def validate_static_credential( auth_type: MCPAuthType, headers: Mapping[str, str], upstream_token_header: str | None = None, + static_header_names: Iterable[str] = (), ) -> Result[None, CredError]: if auth_type not in _STATIC_MODES: return Ok(None) default_slot: Final = "X-API-Key" if auth_type == MCPAuth.api_key else "Authorization" + admin_chosen_slots: Final = tuple(static_header_names) if auth_type == MCPAuth.api_key else () slots: Final = frozenset( name.lower() for name in ( upstream_token_header or default_slot, default_slot, "Authorization", + *admin_chosen_slots, ) ) values: Final = tuple((name.lower(), value.strip()) for name, value in headers.items() if name.lower() in slots) @@ -448,7 +451,9 @@ async def prepare_mcp_client(server: MCPServer, client: MCPClient) -> MCPClient: if server.auth_type not in _STATIC_MODES or client.transport_type == MCPTransport.stdio: return client request: Final = await client.prepare_request_auth() - match validate_static_credential(server.auth_type, request.headers, server.upstream_token_header): + match validate_static_credential( + server.auth_type, request.headers, server.upstream_token_header, server.static_headers or () + ): case Error(error): raise_public(error) case Ok(): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py index 78da9ff4d77..141260db700 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py @@ -23,7 +23,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import to_subject, validate_static_credential, ) -from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok +from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error, Ok from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( ApiKeyConfig, AuthorizationCodeConfig, @@ -59,6 +59,22 @@ def test_static_credential_preserves_supported_api_key_and_raw_headers( assert isinstance(result, Ok) +@pytest.mark.parametrize("auth_type,headers,static_header_names,expected", [ + (MCPAuth.api_key, {"apikey": "static-key"}, ("apikey",), Ok), + (MCPAuth.api_key, {"apikey": "static-key", "X-API-Key": ""}, ("apikey",), Ok), + (MCPAuth.api_key, {"apikey": ""}, ("apikey",), Error), + (MCPAuth.api_key, {"apikey": "static-key"}, (), Error), + (MCPAuth.api_key, {"apikey": "static-key"}, ("X-Tenant",), Error), + (MCPAuth.bearer_token, {"apikey": "static-key"}, ("apikey",), Error), + (MCPAuth.token, {"apikey": "static-key"}, ("apikey",), Error), +]) +def test_static_credential_counts_api_key_static_headers_only( + auth_type: MCPAuthType, headers: dict[str, str], static_header_names: tuple[str, ...], expected: type, +) -> None: + result: Final = validate_static_credential(auth_type, headers, static_header_names=static_header_names) + assert isinstance(result, expected) + + def _server(**kwargs) -> MCPServer: return MCPServer(server_id="s", name="n", transport=MCPTransport.http, **kwargs) 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 186c46e1b37..2fab7a6f4b5 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 @@ -13652,6 +13652,28 @@ class TestProtectedCredentialPreparation: assert client._credential_slot == "X-Custom" assert await client.discovery_auth_fingerprint() + @pytest.mark.asyncio + @pytest.mark.parametrize("static_headers,accepted", [ + ({"apikey": "static-key"}, True), + ({"apikey": ""}, False), + ({"X-Tenant": "tenant"}, True), + ]) + async def test_api_key_carried_by_static_header_passes_fail_closed_check( + self, static_headers: dict[str, str], accepted: bool + ) -> None: + server: Final = MCPServer( + server_id="static-slot", name="static-slot", url="https://upstream.example/mcp", + transport=MCPTransport.http, auth_type=MCPAuth.api_key, static_headers=static_headers, + ) + if not accepted: + with pytest.raises(HTTPException) as exc: + await MCPServerManager()._create_mcp_client(server, extra_headers=dict(static_headers)) + assert exc.value.status_code == 500 + return + client: Final = await MCPServerManager()._create_mcp_client(server, extra_headers=dict(static_headers)) + request: Final = await client.prepare_request_auth() + assert all(request.headers[name] == value for name, value in static_headers.items()) + @pytest.mark.asyncio @pytest.mark.parametrize("static,forwarded,caller", [ ({"X-API-Key": "static"}, {"x-api-key": "forwarded"}, None), diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py index a9def20e75d..bd351f9106e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py @@ -133,6 +133,26 @@ async def test_static_auth_uses_configured_custom_header( assert destination.call_count == 0 +@pytest.mark.asyncio +@pytest.mark.parametrize("credential", ["static-key", ""]) +async def test_static_auth_accepts_api_key_carried_by_static_header( + respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch, credential: str, +) -> None: + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + tool: Final = create_tool_function( + "/echo", "get", {}, "https://upstream.example", headers={"apikey": credential}, auth_type=MCPAuth.api_key, + ) + destination: Final = respx_mock.get("https://upstream.example/echo").respond(200, text="authenticated") + if credential: + assert await tool() == "authenticated" + assert destination.calls.last.request.headers["apikey"] == credential + assert "x-api-key" not in destination.calls.last.request.headers + else: + with pytest.raises(HTTPException, match="requires a usable upstream credential"): + await tool() + assert destination.call_count == 0 + + @pytest.mark.asyncio @pytest.mark.parametrize("auth_type,resolved", [ (MCPAuth.none, None),