From c93d88ed797b83b4433801564866f3b56e331a60 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 26 Sep 2026 22:26:08 +0000 Subject: [PATCH] fix(mcp): never validate a supplied header on the tools/list BYOK path The pre-listing resolver ran the tool-call byok_auth_required check even when the caller already supplied x-mcp-auth, and it ran outside the per-server error boundary, so a single deprecated-header caller dropped the server from the aggregate list. Listing now returns a supplied header unchanged and falls back to the stored credential without raising Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../mcp_server/mcp_server_manager.py | 21 +++++++++++++----- .../mcp_server/test_mcp_server_manager.py | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 4c257451345..074a30eb0db 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -1286,6 +1286,21 @@ async def _resolve_byok_mcp_auth_header( return mcp_auth_header +async def _byok_listing_auth_header( + mcp_server: MCPServer, + user_api_key_auth: UserAPIKeyAuth | None, + mcp_auth_header: str | dict[str, str] | None, +) -> str | dict[str, str] | None: + """The credential a tools/list may use: a supplied header forwards unchanged, and a missing one + falls back to the stored credential without the tool-call path's byok_auth_required raise.""" + if not mcp_server.is_byok or mcp_auth_header is not None: + return mcp_auth_header + + from litellm.proxy._experimental.mcp_server.operations import _get_byok_credential + + return await _get_byok_credential(mcp_server, user_api_key_auth) + + def _client_forwarded_authorization_headers( mcp_server: MCPServer, oauth2_headers: dict[str, str] | None, @@ -4401,11 +4416,7 @@ class MCPServerManager: verbose_logger.info("_get_tools_from_server for %s...", server.name) client = None - resolved_mcp_auth_header: Final = ( - mcp_auth_header - if not server.is_byok or isinstance(mcp_auth_header, dict) - else await _resolve_byok_mcp_auth_header(server, user_api_key_auth, mcp_auth_header) - ) + resolved_mcp_auth_header: Final = await _byok_listing_auth_header(server, user_api_key_auth, mcp_auth_header) listed_caller: Final = ListedToolsCaller( user_api_key_auth=user_api_key_auth, mcp_auth_header=resolved_mcp_auth_header, 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 368f0d09e3c..3683d00d2a3 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 @@ -7068,6 +7068,28 @@ class TestMCPServerManager: listed = manager.get_listed_tool(server, "turn", call_side) assert listed is not None and listed.description == "stored cred catalog" + @pytest.mark.asyncio + async def test_byok_supplied_header_lists_without_credential_validation(self): + manager = MCPServerManager() + server = MCPServer( + server_id="byok-catalog", + name="byok_catalog", + transport=MCPTransport.http, + url="http://byok-catalog", + is_byok=True, + ) + manager._create_mcp_client = AsyncMock(return_value=AsyncMock()) + manager._fetch_tools_with_timeout = AsyncMock(return_value=[MCPTool(name="turn", description="t", inputSchema={})]) + + with patch("litellm.proxy.proxy_server.prisma_client", None): + await manager._get_tools_from_server( + server=server, + mcp_auth_header="Bearer hdr", + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm"), + ) + + assert manager._create_mcp_client.await_args.kwargs["mcp_auth_header"] == "Bearer hdr" + @pytest.mark.parametrize( ("signer", "static_headers", "shared"), [