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>
This commit is contained in:
yucheng 2026-09-12 03:52:06 +00:00
parent b1c3573390
commit 485f4522e4
4 changed files with 26 additions and 1 deletions

View file

@ -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,

View file

@ -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)
)
):

View file

@ -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"}

View file

@ -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):
"""