From 485f4522e4bd207dad925652cde802466dbcfcac Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 12 Sep 2026 03:52:06 +0000 Subject: [PATCH] fix(mcp): scope the Agent 365 connect challenge to single-server routes and drop stale listed tools on invalidation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../_experimental/mcp_server/mcp_server_manager.py | 1 + litellm/proxy/_experimental/mcp_server/server.py | 5 ++++- .../_experimental/mcp_server/test_mcp_server.py | 8 ++++++++ .../mcp_server/test_mcp_server_manager.py | 13 +++++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 35b8aece527..2938f1362af 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -4501,6 +4501,7 @@ class MCPServerManager: self._prompt_discovery_cache.invalidate(server_id) self._resource_discovery_cache.invalidate(server_id) self._template_discovery_cache.invalidate(server_id) + self._listed_tools_by_server_id.pop(server_id, None) def _discovery_key( self, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 545df2ba25b..dc191dbf8b8 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -4144,10 +4144,13 @@ if MCP_AVAILABLE: # header lost, so the discovery flow needs this pre-emptive challenge. Servers gated by an # Agent 365 guardrail (OBO to the evaluate API) get the same challenge, also when the only # bearer is the LiteLLM key itself, which admits the caller but is not an exchangeable subject. + # Only on the server's own route: the per-server metadata ``resource`` must equal the URL the + # client connected to (RFC 9728 3.3), which aggregate ``/mcp`` and multi-server connects never do. if server and ( (server.auth_type == MCPAuth.oauth2_token_exchange and not oauth2_headers) or ( - not agent_365_subject_token_present(oauth2_headers) + _get_mcp_servers_in_path(get_route_relative_request_path(scope)) == [server_name] + and not agent_365_subject_token_present(oauth2_headers) and agent_365_authorization_servers(server, user_api_key_auth) ) ): 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 95103a3ed71..b85c3bc7557 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 @@ -9020,6 +9020,14 @@ class TestAgent365ChallengeAtConnect: in www_authenticate ) + @pytest.mark.asyncio + @pytest.mark.parametrize("path", ["/mcp", "/mcp/tools,other"]) + async def test_aggregate_route_is_not_challenged_at_connect(self, agent_365_guardrail, path): + """The per-server metadata's ``resource`` can never equal the aggregate ``/mcp`` URL the client + connected to (RFC 9728 3.3), and one guarded server must not 401 a multi-server connect, so the + Agent 365 challenge is left to tools/call there.""" + assert await self._connect(self._server([self.GATEWAY_SCOPE]), None, path=path) is None + @pytest.mark.asyncio async def test_entra_assertion_present_connects(self, agent_365_guardrail): bearer = {"Authorization": "Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1LTEifQ.c2ln"} 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 556d8ee6959..768d985a095 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 @@ -6606,6 +6606,19 @@ class TestMCPServerManager: assert by_prefixed_name is not None and by_prefixed_name.description == "v2" assert manager.get_listed_tool(server, "missing") is None + def test_invalidate_discovery_lists_drops_listed_tools(self): + manager = MCPServerManager() + server = MCPServer(server_id="srv", name="srv", transport=MCPTransport.http, url="http://srv") + other = MCPServer(server_id="other", name="other", transport=MCPTransport.http, url="http://other") + manager._create_prefixed_tools([MCPTool(name="echo", description="old", inputSchema={})], server) + manager._create_prefixed_tools([MCPTool(name="ping", description="kept", inputSchema={})], other) + + manager._invalidate_discovery_lists(server.server_id) + + assert manager.get_listed_tool(server, "echo") is None + kept = manager.get_listed_tool(other, "ping") + assert kept is not None and kept.description == "kept" + @pytest.mark.asyncio async def test_get_allowed_mcp_servers_with_user_api_key_auth(self): """