Merge pull request #47804 from BerriAI/litellm_backport_47677_stable_1_105_x

fix(mcp): backport per-user auth MCP tool listing to stable/1.105.x
This commit is contained in:
yuneng-jiang 2026-10-21 00:55:13 -07:00 • committed by GitHub
commit 653f67eafc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 72 additions and 3 deletions

View file

@ -4225,6 +4225,7 @@ class MCPServerManager:
raw_headers: Mapping[str, str] | None = None,
client_ip: str | None = None,
protocol_version_override: MCPUpstreamProtocol | None = None,
for_tool_listing: bool = False,
) -> MCPClient:
"""
Create an MCPClient instance for the given server.
@ -4377,6 +4378,7 @@ class MCPServerManager:
sampling_callback=sampling_cb,
elicitation_callback=elicitation_cb,
),
for_tool_listing=for_tool_listing,
)
# Create SigV4 auth if configured
@ -4421,6 +4423,7 @@ class MCPServerManager:
sampling_callback=sampling_cb,
elicitation_callback=elicitation_cb,
),
for_tool_listing=for_tool_listing,
)
async def _get_tools_from_server(
@ -4529,6 +4532,7 @@ class MCPServerManager:
user_api_key_auth=user_api_key_auth,
raw_headers=raw_headers,
client_ip=client_ip,
for_tool_listing=True,
)
## HANDLE OPENAPI TOOLS

View file

@ -427,6 +427,8 @@ def validate_static_credential(
headers: Mapping[str, str],
upstream_token_header: str | None = None,
static_header_names: Iterable[str] = (),
*,
allow_absent: bool = False,
) -> Result[None, CredError]:
if auth_type not in _STATIC_MODES:
return Ok(None)
@ -442,17 +444,23 @@ def validate_static_credential(
)
)
values: Final = tuple((name.lower(), value.strip()) for name, value in headers.items() if name.lower() in slots)
if allow_absent and not values:
return Ok(None)
if any(_usable_credential_value(auth_type, name, value) for name, value in values):
return Ok(None)
return Error(CredError.of_misconfigured(f"{auth_type} requires a usable upstream credential"))
async def prepare_mcp_client(server: MCPServer, client: MCPClient) -> MCPClient:
async def prepare_mcp_client(server: MCPServer, client: MCPClient, *, for_tool_listing: bool = False) -> 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, server.static_headers or ()
server.auth_type,
request.headers,
server.upstream_token_header,
server.static_headers or (),
allow_absent=for_tool_listing and server.auth_type == MCPAuth.authorization,
):
case Error(error):
raise_public(error)

View file

@ -14047,6 +14047,7 @@ def _mcp_upstream(respond):
class _DiscoveryUpstream:
def __init__(self) -> None:
self.requests: tuple[tuple[str, str], ...] = ()
self.tools: tuple[dict[str, object], ...] = ()
self.outcome = "supported"
self.entered = asyncio.Event()
self.release = asyncio.Event()
@ -14092,7 +14093,7 @@ class _DiscoveryUpstream:
"resources/templates/list": {
"resourceTemplates": [{"name": "example", "uriTemplate": "test://{name}", "description": "original"}]
},
"tools/list": {"tools": []},
"tools/list": {"tools": list(self.tools)},
}[payload.method]
continuation: Final = (
{"nextCursor": "last-page"}
@ -14110,6 +14111,62 @@ def _discovery_server() -> MCPServer:
return MCPServer(server_id="discovery", name="discovery", url="https://discovery.example/mcp", transport=MCPTransport.http)
@pytest.mark.asyncio
async def test_per_user_authorization_server_lists_without_static_credential() -> None:
upstream: Final = _DiscoveryUpstream()
upstream.tools = ({"name": "search", "inputSchema": {"type": "object"}},)
server: Final = MCPServer(
server_id="per-user-auth",
name="per-user-auth",
url="https://discovery.example/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.authorization,
)
manager: Final = MCPServerManager()
manager.registry[server.server_id] = server
with _mcp_upstream(upstream.respond):
tools: Final = await manager._get_tools_from_server(server, add_prefix=False)
assert [tool.name for tool in tools] == ["search"]
assert ("tools/list", "") in upstream.requests
with pytest.raises(HTTPException, match="requires a usable upstream credential"):
await manager._create_mcp_client(server)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("auth_type", "credential", "upstream_token_header"),
(
(MCPAuth.authorization, "Bearer", None),
(MCPAuth.authorization, "Bearer", "X-Token"),
(MCPAuth.authorization, " ", None),
(MCPAuth.authorization, " ", "X-Token"),
(MCPAuth.bearer_token, None, None),
),
)
async def test_tool_listing_rejects_malformed_or_other_missing_static_credentials(
auth_type: MCPAuth, credential: str | None, upstream_token_header: str | None
) -> None:
upstream: Final = _DiscoveryUpstream()
server: Final = MCPServer(
server_id="invalid-static-auth",
name="invalid-static-auth",
url="https://discovery.example/mcp",
transport=MCPTransport.http,
auth_type=auth_type,
authentication_token=credential,
upstream_token_header=upstream_token_header,
)
manager: Final = MCPServerManager()
manager.registry[server.server_id] = server
with _mcp_upstream(upstream.respond), pytest.raises(MCPServerListError):
await manager._get_tools_from_server(server)
assert upstream.requests == ()
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ("prompts", "resources", "templates"))
async def test_discovery_cache_reuses_raw_results_and_expires(kind: str) -> None: