fix(mcp): challenge Agent 365 connects on the mounted /mcp/{server} route

Starlette's /mcp Mount moves the prefix into root_path and leaves the app root in
app_root_path, which is an empty string on a default deployment. Reading it with
`or` fell through to root_path=/mcp and stripped the prefix, so the route-relative
path became /{server} and the per-server connect challenge was skipped there.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-12 04:12:12 +00:00
parent 3c536bfc9a
commit 13596700ec
2 changed files with 30 additions and 2 deletions

View file

@ -199,7 +199,7 @@ def get_route_relative_request_path(scope: Scope) -> str:
:func:`litellm.proxy.auth.auth_utils.get_request_route`, which the rest of the MCP auth path
already routes through, so ``/litellmfoo`` is not truncated under ``root_path=/litellm``."""
raw_path = str(scope.get("_original_path") or scope.get("path", "") or "")
root_path = str(scope.get("app_root_path") or scope.get("root_path") or "").rstrip("/")
root_path = str(scope.get("app_root_path", scope.get("root_path")) or "").rstrip("/")
if root_path and (raw_path == root_path or raw_path.startswith(f"{root_path}/")):
return raw_path[len(root_path) :]
return raw_path

View file

@ -8963,7 +8963,11 @@ class TestAgent365ChallengeAtConnect:
)
async def _connect(
self, server: MCPServer, oauth2_headers: dict[str, str] | None, path: str = "/mcp/tools"
self,
server: MCPServer,
oauth2_headers: dict[str, str] | None,
path: str = "/mcp/tools",
mount_scope: dict[str, str] | None = None,
) -> HTTPException | None:
from litellm.proxy._experimental.mcp_server import server as server_module
@ -8984,6 +8988,7 @@ class TestAgent365ChallengeAtConnect:
"scheme": "https",
"server": ("gw.example.com", 443),
"headers": [],
**(mount_scope or {}),
},
mcp_servers=["tools"],
oauth2_headers=oauth2_headers,
@ -9020,6 +9025,29 @@ class TestAgent365ChallengeAtConnect:
in www_authenticate
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"server_root, mount_scope",
[
("", {"root_path": "/mcp", "app_root_path": ""}),
("/litellm", {"root_path": "/litellm/mcp", "app_root_path": "/litellm"}),
],
)
async def test_mounted_standard_route_is_challenged(self, agent_365_guardrail, server_root, mount_scope):
"""``/mcp/{server}`` is served by the ``/mcp`` Mount, which moves the mount prefix into
``root_path`` and leaves the app root (empty or SERVER_ROOT_PATH) in ``app_root_path``."""
with patch.dict(os.environ, {"SERVER_ROOT_PATH": server_root}):
challenge = await self._connect(
self._server([self.GATEWAY_SCOPE]), None, path=f"{server_root}/mcp/tools", mount_scope=mount_scope
)
assert challenge is not None and challenge.status_code == 401
www_authenticate = (challenge.headers or {}).get("WWW-Authenticate", "")
assert (
f'resource_metadata="https://gw.example.com{server_root}'
f'/.well-known/oauth-protected-resource{server_root}/mcp/tools"' 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):