feat(proxy): resolve root_path per request from a configured prefix list (SERVER_ROOT_PATHS) - #35935
Conversation
One deployment can encode exactly one client-visible URL path prefix today: SERVER_ROOT_PATH is a scalar stamped onto the app at startup, so a pod fronting several ingress prefixes 404s every prefix but one before any handler runs, and MCP OAuth discovery can emit only one prefix's URLs (RFC 9728 section 3 exact-match fails for the rest). Add an opt-in outermost ASGI middleware that matches the request path against a configured prefix list (SERVER_ROOT_PATHS, comma-separated) on a segment boundary and sets scope["root_path"] for that request only. Everything downstream is stock Starlette: route matching strips root_path so routes stay registered root-relative, and request.base_url re-includes it, so the discovery documents' resource and the 401 challenges' resource_metadata land under the prefix the client actually called — with no discovery-builder changes. LazyFeatureMiddleware now strips the scope root_path (falling back to the cached SERVER_ROOT_PATH scalar) before feature prefix matching, so lazily-registered routers — the MCP OAuth discovery router among them — load under per-request prefixes. Follow-up to the routing discussion on BerriAI#35226; composes with, but does not depend on, BerriAI#35576.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThe PR adds opt-in per-request ASGI root-path resolution for deployments serving multiple preserved URL prefixes and updates lazy feature loading to normalize paths against each request's resolved prefix.
Confidence Score: 5/5The PR appears safe to merge, with only non-blocking repository-guideline issues remaining. The runtime root-path implementation has no established blocking failure; the remaining prior-thread concerns are limited to retained commentary and fixture-based mutation of shared test state.
|
| Filename | Overview |
|---|---|
| litellm/proxy/middleware/per_request_root_path_middleware.py | Adds normalized, longest-prefix-first per-request root_path selection without rewriting the ASGI path. |
| litellm/proxy/_lazy_features.py | Makes lazy feature prefix matching use the request scope's root_path before falling back to the scalar configuration. |
| litellm/proxy/proxy_server.py | Conditionally installs per-request root-path resolution as the outermost middleware. |
| tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py | Adds MCP discovery and challenge coverage for prefixed and unprefixed requests. |
| tests/test_litellm/proxy/middleware/test_per_request_root_path_middleware.py | Covers normalization, boundary matching, nested prefixes, scalar overrides, WebSockets, and end-to-end routing. |
| tests/test_litellm/proxy/test_proxy_server.py | Extends lazy feature tests to cover per-request root-path precedence and boundary handling. |
Reviews (2): Last reviewed commit: "review(greptile): trim implementation co..." | Re-trigger Greptile
…istry state in tests Addresses both P2s from the first Greptile pass: - per_request_root_path_middleware.py (and the related _lazy_features / proxy_server comments) cut down to the constraints the code cannot express, per repo comment guidance - the new discovery tests no longer clear/repopulate the shared MCP registry inline; a fixture snapshots it, hands the test an empty registry, and restores it afterwards so no state leaks between cases
|
Both P2s addressed in 548bed2 (commentary trim, net −38 lines; fixture-owned MCP registry state in the new tests — replied on each thread). Local re-validation: 346 tests across the middleware, discovery, and lazy-feature suites green, ruff format/check and the strict-budget gate clean. @greptileai review |
TLDR
Problem this solves:
SERVER_ROOT_PATHis a single scalar stamped onto the app at startup, so one deployment can serve exactly one client-visible URL path prefix: Starlette strips that one prefix before route matching, and a request under any other prefix 404s before a handler runs/tenant-a/*and/tenant-b/*both terminating at the same LiteLLM) is therefore forced into one Deployment per prefix purely to encode it — and MCP OAuth discovery can emit only one prefix's URLs, so every other origin's client aborts on the RFC 9728 §3 exact-match checkroot_path, not the discovery builders" — with the allowed-prefix-list shape proposed in the closing commentHow it solves it:
SERVER_ROOT_PATHS— comma-separated list of the client-visible prefixes the ingress preserves, e.g.SERVER_ROOT_PATHS=/tenant-a,/tenant-bscope["root_path"]to the matched prefix for that request onlyroot_pathviaget_route_path), andrequest.base_urlre-includes it — so the discovery documents'resourceand the 401 challenges'resource_metadataland under the prefix the client actually called, with zero changes to the discovery buildersRelevant issues
root_path, the second prefix 404s before the builder runs, and prefixed AS endpoints would 404. Per-requestroot_pathdissolves both objections: the whole app routes under each configured prefix, so prefixed discovery and prefixed/authorize//token//registerall resolveroot_pathfrom_original_path, which this middleware populates per request — so once both land, the legacy/{server}/mcpspelling selection is also correct under per-request prefixesPre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
Screenshots / Proof of Fix
Live proxy, config below, no database:
BEFORE (same build,
SERVER_ROOT_PATHSunset — i.e. today's behavior): the prefixes are unroutable, exactly as diagnosed in the #35226 discussion:AFTER (
SERVER_ROOT_PATHS="/tenant-a,/tenant-b"): one pod, both prefixes; each 401 challenge advertises a metadata URL under its own prefix:and each advertised document's
resourceis exactly the URL its client called (RFC 9728 §3), per prefix, from the same pod:The AS metadata chain resolves under the prefix too — the 404 trap that invalidated prefixing discovery URLs in #35226's first review round does not exist here, because the whole app routes per-prefix:
Controls, same instance: unprefixed requests emit today's URLs unchanged (
resource: http://127.0.0.1:4102/mcp/github), and an unlisted prefix 404s (/tenant-c/... → 404).Type
🆕 New Feature
Changes
litellm/proxy/middleware/per_request_root_path_middleware.py(new):PerRequestRootPathMiddleware+SERVER_ROOT_PATHSparsing/normalization (whitespace/trailing-slash canonicalization, leading-/validation with a warning, dedupe, longest-first ordering so nested prefixes match most-specific). Setsscope["root_path"]on a segment-boundary match; never rewritesscope["path"](Starlette stripsroot_pathfrom the path at match time — stripping here too would double-strip)litellm/proxy/proxy_server.py: add the middleware last (Starlette's last-added is outermost) and only whenSERVER_ROOT_PATHSis set, so the prefix is resolved before any inner middleware or the router inspects the path and the default stack is untouched. Warns when bothSERVER_ROOT_PATHandSERVER_ROOT_PATHSare configured (a matched prefix overrides the scalar for that request)litellm/proxy/_lazy_features.py:LazyFeatureMiddlewarenow strips the scope'sroot_path(falling back to the cachedSERVER_ROOT_PATHscalar) before feature prefix matching — the MCP discovery router is lazily registered, so without this it would never load under a per-request prefix. For scalar deployments the scope value equals the cached env value, so behavior is unchangedSERVER_ROOT_PATHonesNotes on shape, per the mechanism question left open in the #35226 closing comment (config-file vs env vs header):
SERVER_ROOT_PATHand is available at import time, where the middleware stack is assembled. Happy to move it to ageneral_settingskey if you prefer that surfacePROXY_BASE_URLremains a scalar override for discovery origin resolution — a multi-prefix deployment should leave it unset and control scheme/host via trustedX-Forwarded-*(unchanged behavior)https://host/.well-known/oauth-protected-resource/tenant-a/mcp/github) are not served — same as today. The operative MCP flow is challenge-driven and the advertised prefix-anchored spelling is served and ingress-routable under the tenant prefix. If you want the inserted spellings too, per-prefix route registration is a natural follow-upQA runbook
uv run --no-sync pytest tests/test_litellm/proxy/middleware/ tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py tests/test_litellm/proxy/_experimental/mcp_server/auth/ tests/test_litellm/proxy/test_lazy_openapi_snapshot.py -q(804 passed locally) anduv run --no-sync pytest "tests/test_litellm/proxy/test_proxy_server.py::TestLazyFeatureMiddleware" -qmcp_serversconfig above tomcp_multiprefix_config.yamlSERVER_ROOT_PATHS="/tenant-a,/tenant-b" LITELLM_MASTER_KEY=sk-1234 python litellm/proxy/proxy_cli.py --config mcp_multiprefix_config.yaml --port 4102resourceequals the MCP URL calledSERVER_ROOT_PATHS, and/tenant-c/...404sSERVER_ROOT_PATHSand confirm behavior is identical to upstream (middleware absent from the stack)Final Attestation