mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
Merge pull request #41514 from BerriAI/litellm_mcp_api_key_static_header_slot
fix(mcp): count admin static headers as api_key credential slots
This commit is contained in:
commit
9e1eb546e4
5 changed files with 67 additions and 4 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue