mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
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:
parent
3c536bfc9a
commit
13596700ec
2 changed files with 30 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue