diff --git a/litellm/proxy/_experimental/mcp_server/oauth_utils.py b/litellm/proxy/_experimental/mcp_server/oauth_utils.py index 39865a35ec6..50eca9de2b5 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth_utils.py +++ b/litellm/proxy/_experimental/mcp_server/oauth_utils.py @@ -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 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 b85c3bc7557..cac754feb3f 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 @@ -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):