Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion litellm/proxy/_experimental/mcp_server/oauth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,22 @@ def well_known_root_suffix() -> str:
return "" if root == "/" else root


def get_route_relative_request_path(scope: Scope) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the authorization_uri branch in server.py picks its /mcp/{server_name} vs /{server_name} well-known shape off the same raw scope["_original_path"] (server.py:3767), so under SERVER_ROOT_PATH it takes the else-branch for both spellings and hands back /.well-known/oauth-authorization-server/{server}. Same mismatch this fixes, just the gateway-managed authorization_code path instead of the passthrough one — worth routing that one through get_route_relative_request_path too.

"""The request path the MCP route shapes are written against: the raw ASGI path with the
deployment's ``root_path`` removed.

``scope["path"]`` and ``_original_path`` are both raw request-line paths, so on a sub-path
deployment they still carry the ``SERVER_ROOT_PATH`` prefix (``/litellm/{server}/mcp``) while
every route shape compared against them is root-relative. Mirrors the segment-boundary strip in
: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("/")
if root_path and (raw_path == root_path or raw_path.startswith(f"{root_path}/")):
return raw_path[len(root_path) :]
return raw_path


def get_passthrough_resource_metadata_url(scope: Scope, server_name: str) -> str:
"""The per-server protected-resource metadata URL matching the spelling the request
arrived on, so a strict RFC 9728 client resolves the same route the proxy registered.
Expand All @@ -188,7 +204,7 @@ def get_passthrough_resource_metadata_url(scope: Scope, server_name: str) -> str
the route decorators insert it (see :func:`well_known_root_suffix`)."""
request = Request(scope)
base_url = get_request_base_url(request)
_path = scope.get("_original_path") or scope.get("path", "") or ""
_path = get_route_relative_request_path(scope)

if _path.startswith(f"/{server_name}/mcp"):
return f"{base_url}/.well-known/oauth-protected-resource{well_known_root_suffix()}/{server_name}/mcp"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6314,6 +6314,49 @@ async def test_per_server_challenge_for_gateway_managed_oauth2(self):
www_authenticate = (exc_info.value.headers or {})["WWW-Authenticate"]
assert www_authenticate == f'Bearer resource_metadata="http://testserver{expected_metadata_path}"'

async def test_per_server_challenge_keeps_spelling_under_server_root_path(self):
"""On a sub-path deployment the challenge must still advertise the spelling the client
used. ``_original_path`` is a raw request-line path, so under SERVER_ROOT_PATH it reads
``/litellm/{server}/mcp``; matching that against the root-relative ``/{server}/mcp`` shape
used to fail, silently pointing a legacy-spelling client at the standard-pattern document
whose ``resource`` is ``{base}/mcp/{server}`` rather than the ``{base}/{server}/mcp`` URL it
called, which a strict RFC 9728 section 3 client rejects."""
import os

from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer

server = MCPServer(
server_id="gh-id",
name="github",
server_name="github",
url="https://upstream.example/mcp",
transport="http",
auth_type=MCPAuth.oauth2,
)
for original_path, expected_metadata_path in (
("/litellm/mcp/github", "/litellm/.well-known/oauth-protected-resource/litellm/mcp/github"),
("/litellm/github/mcp", "/litellm/.well-known/oauth-protected-resource/litellm/github/mcp"),
):
scope = {
**self._scope(path="/mcp/github"),
"root_path": "/litellm",
"_original_path": original_path,
}
with (
patch.dict(os.environ, {"SERVER_ROOT_PATH": "/litellm"}),
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
) as mock_mgr,
):
mock_mgr.get_mcp_server_by_name.return_value = server
with pytest.raises(HTTPException) as exc_info:
await MCPRequestHandler.process_mcp_request(scope)
assert exc_info.value.status_code == 401
www_authenticate = (exc_info.value.headers or {})["WWW-Authenticate"]
assert www_authenticate == f'Bearer resource_metadata="http://testserver{expected_metadata_path}"'

async def test_no_per_server_challenge_for_non_gateway_managed_targets(self):
"""The per-server challenge fires only for the server set the gateway's keyless flow
serves: an OBO server and a multi-server CSV path keep the original admission error
Expand Down
Loading