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
19 changes: 11 additions & 8 deletions litellm/proxy/_lazy_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,15 +292,18 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
# Short-circuit once every feature has loaded.
if scope["type"] in ("http", "websocket") and len(self._loaded) < len(self._features):
path = scope.get("path", "")
# Strip SERVER_ROOT_PATH so prefix matching works under a server
# root path. Without this, requests like /api/v1/policies/... never
# match the registered prefixes (/policies/...) and lazy features
# stay unloaded — every endpoint under them returns 404. The
# Strip the request's root_path so prefix matching works under a
# server root path. Without this, requests like /api/v1/policies/...
# never match the registered prefixes (/policies/...) and lazy
# features stay unloaded — every endpoint under them returns 404.
# scope["root_path"] wins over the cached env scalar: FastAPI
# stamps SERVER_ROOT_PATH there, and PerRequestRootPathMiddleware
# resolves SERVER_ROOT_PATHS prefixes there per request. The
# `+ "/"` boundary prevents false-positive matches (e.g. /apiv2
# against root /api). If the path doesn't start with the prefix
# (e.g. a reverse proxy already stripped it), we leave it alone.
if self._root_path and path.startswith(self._root_path + "/"):
path = path[len(self._root_path) :]
# against root /api); a pre-stripped path is left alone.
root_path = str(scope.get("root_path", "")).rstrip("/") or self._root_path
if root_path and path.startswith(root_path + "/"):
path = path[len(root_path) :]
for feat in self._features:
if feat.module_path in self._loaded:
continue
Expand Down
81 changes: 81 additions & 0 deletions litellm/proxy/middleware/per_request_root_path_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Per-request ``root_path`` resolution from ``SERVER_ROOT_PATHS``.

``SERVER_ROOT_PATH`` is a startup scalar, so one deployment serves exactly one
client-visible URL path prefix; a request under any other prefix 404s before a
handler runs. When the ingress preserves several prefixes into one pod (e.g.
``/tenant-a/*`` and ``/tenant-b/*``), the matched prefix becomes that
request's ``scope["root_path"]`` instead: Starlette strips it during route
matching and rebuilds it into ``request.base_url``, so every emitted URL —
the MCP OAuth discovery ``resource`` (RFC 9728 §3) and the 401 challenges'
``resource_metadata`` among them — lands under the prefix the client called.
Opt-in: with ``SERVER_ROOT_PATHS`` unset the middleware is not added at all.
"""

import os
from collections.abc import Sequence
from typing import Final

from starlette.types import ASGIApp, Receive, Scope, Send

from litellm._logging import verbose_proxy_logger

SERVER_ROOT_PATHS_ENV: Final = "SERVER_ROOT_PATHS"


def normalize_root_paths(raw_paths: Sequence[str]) -> tuple[str, ...]:
"""Strip whitespace and trailing slashes, dedupe, order longest-first;
warn and drop entries missing a leading ``/`` and the bare root."""
kept: list[str] = [] # mutable-ok: local accumulator; escapes only as a tuple
for entry in raw_paths:
candidate = entry.strip()
if not candidate:
continue
if not candidate.startswith("/"):
verbose_proxy_logger.warning(
"%s entry %r does not start with '/' and will be ignored.",
SERVER_ROOT_PATHS_ENV,
entry,
)
continue
candidate = candidate.rstrip("/")
if not candidate:
verbose_proxy_logger.warning(
"%s entry %r is the bare root and will be ignored; a root-mounted deployment needs no entry.",
SERVER_ROOT_PATHS_ENV,
entry,
)
continue
if candidate not in kept:
kept.append(candidate)
return tuple(sorted(kept, key=len, reverse=True))


def get_server_root_paths() -> tuple[str, ...]:
"""The normalized ``SERVER_ROOT_PATHS`` prefixes, empty when unset."""
configured: Final = os.getenv(SERVER_ROOT_PATHS_ENV, "")
if not configured.strip():
return ()
return normalize_root_paths(configured.split(","))


class PerRequestRootPathMiddleware:
"""Sets ``scope["root_path"]`` to the configured prefix matching the
request path on a whole-segment boundary. ``scope["path"]`` is left
untouched (Starlette strips ``root_path`` at route-match time). Must be
the outermost middleware so inner middlewares and the router see the
resolved value; a matched prefix overrides a scalar ``SERVER_ROOT_PATH``
for that request.
"""

def __init__(self, app: ASGIApp, root_paths: Sequence[str]) -> None:
self.app = app
self.root_paths: Final = normalize_root_paths(root_paths)

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] in ("http", "websocket"):
path: Final = scope.get("path", "")
for prefix in self.root_paths:
if path == prefix or path.startswith(prefix + "/"):
scope["root_path"] = prefix
break
await self.app(scope, receive, send)
20 changes: 20 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,10 @@ def generate_feedback_box():
from litellm.proxy.middleware.in_flight_requests_middleware import (
InFlightRequestsMiddleware,
)
from litellm.proxy.middleware.per_request_root_path_middleware import (
PerRequestRootPathMiddleware,
get_server_root_paths,
)
from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMiddleware
from litellm.proxy.middleware.request_size_limit_middleware import (
RequestSizeLimitMiddleware,
Expand Down Expand Up @@ -16482,6 +16486,22 @@ async def get_routes():
get_max_request_size_mb=lambda: general_settings.get("max_request_size_mb"),
is_request_size_limit_enabled=lambda: premium_user is True,
)
# Added last on purpose — last-added is outermost, and the client-visible URL
# prefix must be resolved into scope["root_path"] before any inner middleware
# or the router inspects the path. Only added when SERVER_ROOT_PATHS is
# configured, so the default deployment's middleware stack is unchanged.
_server_root_paths: Final = get_server_root_paths()
if _server_root_paths:
if server_root_path and server_root_path != "/":
verbose_proxy_logger.warning(
"Both SERVER_ROOT_PATH=%r and SERVER_ROOT_PATHS=%r are set. A request "
"matching a SERVER_ROOT_PATHS prefix overrides the scalar root_path for "
"that request; unmatched requests keep SERVER_ROOT_PATH. Configure one "
"mechanism or the other.",
server_root_path,
_server_root_paths,
)
app.add_middleware(PerRequestRootPathMiddleware, root_paths=_server_root_paths)


async def _stream_mcp_asgi_response(handle_fn, scope: dict, receive) -> "StreamingResponse":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8457,6 +8457,8 @@ async def test_authorize_wall_names_the_issuer_for_anchored_servers():
assert "verify the Issuer" in detail_text
assert "Servers with no url" not in detail_text
assert "idp.example.com" not in detail_text


def test_passthrough_authorization_code_round_trips_and_rejects_hostile_input():
"""The passthrough gateway code seals and recovers the ephemeral DCR client and upstream code,
and is total over hostile input: a raw upstream code opens to None, and a tampered or
Expand Down Expand Up @@ -8865,7 +8867,9 @@ async def test_mint_ephemeral_dcr_client_unusable_registration_response_is_502(p
)
from litellm.types.mcp import MCPAuth

server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id=server_id, server_name=server_id)
server = _bridge_server(
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id=server_id, server_name=server_id
)
mock_response = MagicMock()
mock_response.text = json.dumps(payload)
mock_response.raise_for_status = MagicMock()
Expand Down Expand Up @@ -8943,8 +8947,6 @@ async def test_token_exchange_authenticates_with_the_sealed_clients_own_auth_met
assert sent_body["client_secret"] == "mint-secret"




# ---------------------------------------------------------------------------
# LIT-4339: RFC 8707 resource indicators on the upstream OAuth legs
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -9197,7 +9199,9 @@ def test_upstream_resource_auto_keeps_the_path_because_it_identifies_the_server(
sets ``upstream_resource`` explicitly instead of using ``auto``."""
from litellm.proxy._experimental.mcp_server.oauth_utils import resolve_upstream_resource

first = resolve_upstream_resource(_resource_server(url="https://gw.example.com/team-a/mcp", upstream_resource="auto"))
first = resolve_upstream_resource(
_resource_server(url="https://gw.example.com/team-a/mcp", upstream_resource="auto")
)
second = resolve_upstream_resource(
_resource_server(url="https://gw.example.com/team-b/mcp", upstream_resource="auto")
)
Expand Down Expand Up @@ -9241,3 +9245,166 @@ async def test_upstream_resource_sent_on_dcr_bridge_relay_authorize():
query = await _authorize_query(server)
assert query["resource"] == ["https://mcp.example.com/mcp"]
assert query["client_id"] == ["caller-client"]


# ---------------------------------------------------------------------------
# Per-request root_path (SERVER_ROOT_PATHS / PerRequestRootPathMiddleware):
# one app fronting several client-visible URL path prefixes, each prefix's
# discovery documents emitting URLs under the prefix the client called
# (RFC 9728 §3 exact-match). A scalar PROXY_BASE_URL / SERVER_ROOT_PATH can
# encode at most one prefix per pod; these tests pin the N-prefix case.
# ---------------------------------------------------------------------------


@pytest.fixture
def _no_proxy_base_url(monkeypatch):
"""Discovery must derive URLs from the request in these tests, so the
scalar env overrides are cleared."""
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)


@pytest.fixture
def _isolated_mcp_registry():
"""Fixture-owned registry state: snapshot the shared registry, hand the
test an empty one, restore afterwards so nothing leaks between cases."""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)

saved = dict(global_mcp_server_manager.registry)
global_mcp_server_manager.registry.clear()
try:
yield global_mcp_server_manager.registry
finally:
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry.update(saved)


def _prefixed_discovery_client(prefixes):
from fastapi import FastAPI
from fastapi.testclient import TestClient

from litellm.proxy._experimental.mcp_server.discoverable_endpoints import router
from litellm.proxy.middleware.per_request_root_path_middleware import (
Comment thread
greptile-apps[bot] marked this conversation as resolved.
PerRequestRootPathMiddleware,
)

app = FastAPI()
app.include_router(router)
app.add_middleware(PerRequestRootPathMiddleware, root_paths=prefixes)
return TestClient(app)


class TestPerRequestRootPathDiscovery:
def test_prefixed_wellknown_not_routable_without_middleware(self, _isolated_mcp_registry):
"""Control: on a plain app (the only shape a scalar root_path can
express), a prefixed well-known request 404s before any discovery
builder runs — the routing gap this feature exists to close."""
from fastapi import FastAPI
from fastapi.testclient import TestClient

from litellm.proxy._experimental.mcp_server.discoverable_endpoints import router

server = _create_oauth2_server(server_id="srv_a", name="server_a", server_name="server_a", alias="server_a")
_isolated_mcp_registry[server.server_id] = server
app = FastAPI()
app.include_router(router)
client = TestClient(app)
resp = client.get("/tenant-a/.well-known/oauth-protected-resource/mcp/server_a")
assert resp.status_code == 404

def test_two_prefixes_one_app_each_resource_matches_the_called_url(
self, _no_proxy_base_url, _isolated_mcp_registry
):
"""The multi-origin case itself: two prefixes served by the same app,
each per-server document's ``resource`` equal to the URL its client
called — including the prefix."""
for sid, name in (("srv_a", "server_a"), ("srv_b", "server_b")):
_isolated_mcp_registry[sid] = _create_oauth2_server(server_id=sid, name=name, server_name=name, alias=name)
client = _prefixed_discovery_client(["/tenant-a", "/tenant-b"])

resp_a = client.get("/tenant-a/.well-known/oauth-protected-resource/mcp/server_a")
resp_b = client.get("/tenant-b/.well-known/oauth-protected-resource/mcp/server_b")

assert resp_a.status_code == 200
assert resp_a.json()["resource"] == "http://testserver/tenant-a/mcp/server_a"
assert resp_b.status_code == 200
assert resp_b.json()["resource"] == "http://testserver/tenant-b/mcp/server_b"

# Every URL the document advertises stays under the request's
# prefix, so it resolves on this same app.
for auth_server in resp_a.json()["authorization_servers"]:
assert auth_server.startswith("http://testserver/tenant-a/")

def test_unprefixed_requests_unchanged_on_the_same_app(self, _no_proxy_base_url, _isolated_mcp_registry):
"""Backward compat on the very same app: a root request emits the
document byte-identical to a deployment without the middleware."""
server = _create_oauth2_server(server_id="srv_a", name="server_a", server_name="server_a", alias="server_a")
_isolated_mcp_registry[server.server_id] = server
client = _prefixed_discovery_client(["/tenant-a"])
resp = client.get("/.well-known/oauth-protected-resource/mcp/server_a")
assert resp.status_code == 200
assert resp.json()["resource"] == "http://testserver/mcp/server_a"

def test_unlisted_prefix_404s(self, _no_proxy_base_url):
client = _prefixed_discovery_client(["/tenant-a"])
assert client.get("/tenant-c/.well-known/oauth-protected-resource/mcp/server_a").status_code == 404

def test_aggregate_documents_and_as_endpoints_under_prefix(self, _no_proxy_base_url, _isolated_mcp_registry):
"""Aggregate PRM/AS documents carry the prefix, and the advertised
authorize endpoint actually resolves under it — the 404 trap that
invalidated prefixing discovery URLs without per-request routing
(#35226 review round 1)."""
client = _prefixed_discovery_client(["/tenant-a"])

prm = client.get("/tenant-a/.well-known/oauth-protected-resource/mcp")
asm = client.get("/tenant-a/.well-known/oauth-authorization-server/mcp")

assert prm.status_code == 200
assert prm.json()["resource"] == "http://testserver/tenant-a/mcp"
assert prm.json()["authorization_servers"] == ["http://testserver/tenant-a/mcp"]

assert asm.status_code == 200
assert asm.json()["issuer"] == "http://testserver/tenant-a/mcp"
assert asm.json()["authorization_endpoint"] == "http://testserver/tenant-a/authorize"

# The prefixed authorize URL routes to the real handler (not 404):
# under per-request root_path the whole app is reachable per-prefix,
# so discovery may advertise prefixed AS endpoints safely.
assert client.get("/tenant-a/authorize").status_code != 404

def test_passthrough_challenge_metadata_url_carries_prefix(self, _no_proxy_base_url):
"""The WWW-Authenticate resource_metadata URL a 401 advertises must
land under the request's prefix, or the client is bounced to a
document whose ``resource`` cannot match the URL it called."""
from litellm.proxy._experimental.mcp_server.oauth_utils import (
get_passthrough_resource_metadata_url,
)

def _scope(path, root_path=None):
scope = {
"type": "http",
"method": "POST",
"path": path,
"headers": [],
"scheme": "http",
"server": ("testserver", 80),
"client": ("1.2.3.4", 4444),
}
if root_path is not None:
scope["root_path"] = root_path
return scope

prefixed = get_passthrough_resource_metadata_url(
scope=_scope("/tenant-a/mcp/github", root_path="/tenant-a"),
server_name="github",
)
assert prefixed == "http://testserver/tenant-a/.well-known/oauth-protected-resource/mcp/github"

# Regression guard: no root_path → today's URL, unchanged.
bare = get_passthrough_resource_metadata_url(
scope=_scope("/mcp/github"),
server_name="github",
)
assert bare == "http://testserver/.well-known/oauth-protected-resource/mcp/github"
Loading
Loading