Skip to content

fix(mcp): derive OAuth discovery URLs from request path when opt-in - #35226

Closed
gym-cmd wants to merge 6 commits into
BerriAI:litellm_internal_stagingfrom
gym-cmd:fix/mcp-oauth-discovery-per-request-resource
Closed

fix(mcp): derive OAuth discovery URLs from request path when opt-in#35226
gym-cmd wants to merge 6 commits into
BerriAI:litellm_internal_stagingfrom
gym-cmd:fix/mcp-oauth-discovery-per-request-resource

Conversation

@gym-cmd

@gym-cmd gym-cmd commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • resource in MCP OAuth discovery is derived from PROXY_BASE_URL only
  • One pod fronting multiple MCP origins under distinct URL path prefixes emits a resource value that matches at most one prefix
  • Other prefixes' clients abort discovery on the RFC 9728 §3 exact-match check

How it solves it:

  • New env MCP_OAUTH_DISCOVERY_PATH_FROM_REQUEST=true opts a deployment in
  • Discovery-doc builders append the client-visible URL path prefix (segments before /.well-known/) to the base
  • Default behaviour is unchanged — existing deployments emit an identical document

Relevant issues

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • Greptile Confidence Score ≥ 4/5 (will confirm after review)

Screenshots / Proof of Fix

Reproduction on this branch before the fix applied (test file present, code file at upstream tip). Commit SHA of upstream baseline: 4d54324515.

$ pytest tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py::test_oauth_protected_resource_uses_request_path_prefix_when_opt_in --tb=long

E       AssertionError: resource must include the request path prefix so it matches the URL the client called (RFC 9728 §3)
E       assert 'https://mcp..../mcp/server_a' == 'https://mcp..../mcp/server_a'
E
E         - https://mcp.example.com/tenant-a/mcp/server_a
E         ?                         ---------
E         + https://mcp.example.com/mcp/server_a

Interpretation: with PROXY_BASE_URL=https://mcp.example.com and the client hitting https://mcp.example.com/tenant-a/.well-known/oauth-protected-resource/mcp/server_a, the discovery response emits resource=https://mcp.example.com/mcp/server_a — dropping the /tenant-a prefix. An RFC 9728 client that verifies exact match on the resource field will abort.

After the fix (same test, this branch's HEAD 0ea9994c6d):

$ pytest tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py -k "oauth_discovery_base_url or protected_resource_uses_request_path_prefix or protected_resource_default_ignores_request_path or protected_resource_opt_in_with_root_mounted" -q

5 passed, 303 deselected, 1 warning in 2.10s

Full test_discoverable_endpoints.py module (308 tests) passes cleanly on this branch.

Type

🐛 Bug Fix

Changes

Why

RFC 9728 §3 requires the resource value in an OAuth protected-resource discovery document to exactly match the URL the client called. LiteLLM's MCP discovery builders derive every URL from get_request_base_url(request), whose resolution order is PROXY_BASE_URL first, X-Forwarded-* next, request.base_url last. That's the correct policy for origin (host + scheme), but it drops the path prefix the client actually used — because PROXY_BASE_URL is a scalar env baked in at pod startup.

Consequence for multi-tenant setups: a single LiteLLM pod that fronts more than one MCP origin under distinct URL path prefixes (e.g. /tenant-a/mcp/… and /tenant-b/mcp/… both routed to the same LiteLLM) can encode at most one prefix in PROXY_BASE_URL. Any origin whose prefix doesn't match sees a discovery response with the wrong resource; every RFC 9728-strict client aborts before the MCP request even fires. Operators end up forced into one Deployment per origin purely to encode a per-origin PROXY_BASE_URL, even though the rest of the workload (DB, Redis, config) is homogeneous.

What

  1. oauth_utils.py: add get_oauth_discovery_base_url(request). Wraps get_request_base_url and — when MCP_OAUTH_DISCOVERY_PATH_FROM_REQUEST is truthy — appends the client-visible URL path prefix (segments before /.well-known/ in request.url.path) to the resolved base.
  2. discoverable_endpoints.py: swap the four discovery-doc builders (per-server + aggregate; protected-resource + auth-server) plus the OpenID JWKS augmentation over to the new helper. Non-discovery callers of get_request_base_url are left untouched.
  3. Tests: two backward-compat guards (default env unset + root-mounted discovery route), one prefix-derivation assertion on the per-server response, and two direct unit tests on the helper.

Backward compatibility

Zero. The helper is a pass-through to get_request_base_url unless the new env var is explicitly set. Existing deployments emit an identical discovery document with no config change.

Why opt-in

A reverse proxy that rewrites the path before LiteLLM sees the request (e.g. an Nginx that strips a prefix) would produce an incorrect prefix under this new mode. The env var makes the operator affirm that their topology preserves the client-visible prefix — which is trivially true for the common ALB / path-based ingress topology, but not something LiteLLM can auto-detect.

Alternatives considered

  • Per-mcp-server resource_base_url config field. Requires operators to hardcode every origin's routing prefix into mcp_servers.<name>.resource_base_url. Works but is fragile: renaming a routing prefix now requires editing every server row that shares it. The request-URI-derived approach is dynamic and DRY.
  • Auto-enable when detecting a path prefix. Rejected: reverse-proxy behaviour is site-specific and undetectable from inside LiteLLM.

QA runbook

Local:

uv sync --inexact --frozen --extra proxy --group proxy-dev
uv run --no-sync pytest tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py -q
uv run --no-sync ruff check litellm/proxy/_experimental/mcp_server/oauth_utils.py litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
uv run --no-sync ruff format --check litellm/proxy/_experimental/mcp_server/oauth_utils.py litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py

End-to-end (against a running proxy):

  1. Deploy LiteLLM behind an ALB / ingress that path-routes /tenant-a/* and /tenant-b/* to the same LiteLLM pod, both with PROXY_BASE_URL=https://mcp.example.com (host-only, no prefix).
  2. Set MCP_OAUTH_DISCOVERY_PATH_FROM_REQUEST=true.
  3. Curl each discovery endpoint:
    • curl https://mcp.example.com/tenant-a/.well-known/oauth-protected-resource/mcp/<server> → assert resource == "https://mcp.example.com/tenant-a/mcp/<server>"
    • curl https://mcp.example.com/tenant-b/.well-known/oauth-protected-resource/mcp/<server> → assert resource == "https://mcp.example.com/tenant-b/mcp/<server>"
  4. Baseline (env var unset): both responses fall back to resource == "https://mcp.example.com/mcp/<server>", matching pre-existing behaviour.

RFC 9728 §3 requires the ``resource`` value in an OAuth protected-resource
discovery document to exactly match the URL the client called. Today the
MCP discovery-doc builders derive every URL from ``PROXY_BASE_URL`` (a
scalar env var) alone, so a single LiteLLM pod that fronts more than one
MCP origin mounted at distinct URL path prefixes emits a ``resource``
that matches at most one prefix; every other prefix's client aborts
discovery before the first request fires.

Add ``get_oauth_discovery_base_url()`` in oauth_utils, an opt-in helper
that appends the client-visible URL path prefix (segments before
``/.well-known/``) to the resolved base when
``MCP_OAUTH_DISCOVERY_PATH_FROM_REQUEST=true``. Wire the four discovery-
doc builders (per-server + aggregate; protected-resource + auth-server)
plus the OpenID JWKS augmentation to use it. Default behaviour is
unchanged — existing deployments emit an identical discovery document
without touching config.

Opt-in because a reverse proxy that rewrites the path before LiteLLM
sees the request would produce an incorrect prefix; operators affirm
their topology preserves the client-visible prefix by setting the env
var.

Tests: two backward-compat guards (default + root-mounted route), one
prefix-derivation assertion on the per-server response, and two direct
unit tests on the helper. All exercise the fixture shape existing MCP
discovery tests already use.
@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.23810% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ellm/proxy/_experimental/mcp_server/oauth_utils.py 94.59% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@gym-cmd
gym-cmd marked this pull request as ready for review July 30, 2026 09:52
Comment thread litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds opt-in request-path-aware MCP OAuth resource discovery and preserves the request prefix in aggregate and per-server authentication challenges

  • Derives protected-resource resource values from the incoming discovery path when explicitly enabled
  • Keeps authorization-server URLs unprefixed so they continue targeting root-mounted handlers
  • Updates aggregate and per-server challenges to advertise prefix-preserving discovery URLs
  • Adds regression coverage for prefixed, root-mounted, default, aggregate, and per-server flows

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains; the previously reported endpoint-prefix and authentication-challenge issues are addressed by separating resource and authorization-server bases and preserving prefixes in challenge metadata URLs

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/oauth_utils.py Adds opt-in path-prefix derivation and prefix-preserving protected-resource metadata URL helpers
litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py Applies the request-derived prefix only to protected-resource resource values while retaining root-relative authorization-server URLs
litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py Uses the new aggregate metadata helper when constructing gateway authentication challenges
tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py Adds focused regression coverage for discovery documents and authentication challenges across prefixed and default configurations

Reviews (4): Last reviewed commit: "fix(mcp): preserve request path prefix o..." | Re-trigger Greptile

Address Greptile review on BerriAI#35226. Prefixing the authorization-server
metadata document (authorization_endpoint, token_endpoint,
registration_endpoint, jwks_uri) or the protected-resource
authorization_servers list broke the default deployment: those handlers
are mounted only at root-relative paths, so a prefixed advertisement
would 404 the client mid-flow.

Only the RFC 9728 §3 ``resource`` value is now derived from the request
path prefix. Every other URL in the discovery documents stays on the
un-prefixed base returned by ``get_request_base_url``.

Split ``_build_oauth_protected_resource_response`` and
``_build_aggregate_protected_resource_response`` to compute both bases;
revert ``_build_oauth_authorization_server_response``,
``_build_aggregate_authorization_server_response``, and the OpenID JWKS
augmentation to the un-prefixed base entirely. Tests: change the
per-server prefix assertion to check ``authorization_servers`` stays
un-prefixed, add an aggregate-doc analogue, and add an auth-server
regression guard that the opt-in must not leak into the AS metadata.
@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing gym-cmd:fix/mcp-oauth-discovery-per-request-resource (f4b2d86) with litellm_internal_staging (05c9815)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (3c2264c) during the generation of this report, so 05c9815 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@milan-berri

Copy link
Copy Markdown
Collaborator

@greptile-apps review again

Comment thread litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
@gym-cmd
gym-cmd requested a review from a team July 31, 2026 09:32
gym-cmd added 2 commits July 31, 2026 10:36
…per-request-resource

Resolve additive conflict in oauth_utils.py by keeping both the PR's
opt-in discovery helpers (get_oauth_discovery_base_url and support) and
upstream's shared passthrough challenge helpers
(get_passthrough_resource_metadata_url / get_passthrough_www_authenticate)
introduced in commit 79d4962.
The passthrough WWW-Authenticate helper built the resource_metadata URL
from get_request_base_url alone, so an anonymous client hitting a
path-prefixed per-server or aggregate MCP endpoint (e.g. /tenant-a/mcp/server_a)
was pointed at an unprefixed .well-known URL. The discovery builder then
saw no prefix on that follow-up request and returned an unprefixed
resource, tripping the RFC 9728 section 3 exact-match check and aborting
discovery on strict clients.

When MCP_OAUTH_DISCOVERY_PATH_FROM_REQUEST is enabled,
get_passthrough_resource_metadata_url now carries the reverse-proxy path
prefix into the challenge URL so the follow-up well-known request lands
on the same prefixed route the resource value will be derived from.
Route-shape detection switches from startswith to endswith so a
prefix-pushed legacy /tenant-a/{server}/mcp shape is recognised too;
without opt-in and without a prefix the emitted URL is byte-identical
to the previous behaviour.

Also refresh two tests that asserted per-server authorization_servers,
which upstream (LIT-4864) changed to the aggregate /mcp AS for
explicitly-named gateway-managed OAuth2 servers.
@veria-ai

veria-ai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

Reflow lines under the 120-column budget on oauth_utils.py and the
matching test module; formatter-only, no behaviour change.
@gym-cmd
gym-cmd force-pushed the fix/mcp-oauth-discovery-per-request-resource branch from c5ed78b to 34f050a Compare July 31, 2026 09:55
@gym-cmd

gym-cmd commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on top of the current litellm_internal_staging (previous head had an ad-hoc merge that pulled in stale drift and left the PR conflicting; the branch now merges cleanly and the diff is exactly the three OAuth discovery files). Ruff format applied to the two touched files. The two open veria-ai comments hang off unchanged upstream code (#35146 auth entitlements, #35155 guardrail translation) that this PR no longer touches; replied on each thread. Full test module (317 tests) green locally.

@greptileai review again

The per-server passthrough challenge already round-trips the request
prefix into its WWW-Authenticate URL, but the aggregate /mcp
fallback in _gateway_dcr_challenge still built the challenge URL from
the un-prefixed base. A client on /tenant-a/mcp was told to fetch
an un-prefixed well-known route, the aggregate discovery builder read
a request with no prefix, and the returned resource failed the RFC
9728 §3 exact-match against the original prefixed URL.

Adds get_aggregate_resource_metadata_url (mirroring the per-server
helper) and wires it into the aggregate branch of the DCR challenge,
plus three unit tests on the helper (opt-in prefix, default no-op,
root-mounted no-op) and two end-to-end tests through
_gateway_dcr_challenge (opt-in prefixed, default un-prefixed).
@gym-cmd

gym-cmd commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Aggregate-endpoint fix landed in f4b2d86. _gateway_dcr_challenge now routes its aggregate branch through a new get_aggregate_resource_metadata_url helper that mirrors the per-server one, so the /tenant-a/mcp challenge carries the request prefix into the well-known URL and the discovery builder returns a matching resource. Regression suite covers helper direct (opt-in / default / root-mount) and end-to-end through the DCR challenge. Verified TDD red-green: the end-to-end tests fail against the previous unprefixed builder and pass on this commit. Full test module still green (322 passed).

@greptileai review again

@tin-berri

Copy link
Copy Markdown
Contributor

Thanks for digging into this. The part of your diff that fixes a bug we can reproduce is the route-shape detection: _original_path is a raw request-line path, so under SERVER_ROOT_PATH it reads /litellm/{server}/mcp and the root-relative startswith check never matches, which sends a legacy-spelling client to the standard-pattern document whose resource is not the URL it called. We took that and landed it as #35576, normalizing root_path off the path the same way get_request_route already does for the rest of the MCP auth path rather than switching to endswith, with a live before/after on a sub-path proxy

On the opt-in env var we are going to pass. The multi-prefix topology it targets is not routable today: app = FastAPI(root_path=get_server_root_path()) is a single scalar and Starlette strips exactly that prefix before matching, so with root_path=/tenant-a a request to /tenant-b/.well-known/... 404s before the discovery builder runs, and with root_path unset both prefixes 404. In the one combination where the helper does fire, base_url already ends in the prefix and well_known_root_suffix() inserts it again, so the emitted URL carries it more than once

For a single prefix per pod, which is what routing supports, resource already comes out right two ways: leave PROXY_BASE_URL unset and let SERVER_ROOT_PATH flow through request.base_url, or set PROXY_BASE_URL=https://mcp.example.com/tenant-a, since _resolve_proxy_base_url_env preserves the path component. If your ingress genuinely preserves distinct prefixes into one pod, the missing piece is per-request root_path, not the discovery builders, and that is a much larger change worth its own issue

Happy to be wrong on the routing point if you have a deployment where the prefixed .well-known request actually reaches the handler; that would change the picture

@gym-cmd

gym-cmd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for digging into this. The part of your diff that fixes a bug we can reproduce is the route-shape detection: _original_path is a raw request-line path, so under SERVER_ROOT_PATH it reads /litellm/{server}/mcp and the root-relative startswith check never matches, which sends a legacy-spelling client to the standard-pattern document whose resource is not the URL it called. We took that and landed it as #35576, normalizing root_path off the path the same way get_request_route already does for the rest of the MCP auth path rather than switching to endswith, with a live before/after on a sub-path proxy

On the opt-in env var we are going to pass. The multi-prefix topology it targets is not routable today: app = FastAPI(root_path=get_server_root_path()) is a single scalar and Starlette strips exactly that prefix before matching, so with root_path=/tenant-a a request to /tenant-b/.well-known/... 404s before the discovery builder runs, and with root_path unset both prefixes 404. In the one combination where the helper does fire, base_url already ends in the prefix and well_known_root_suffix() inserts it again, so the emitted URL carries it more than once

For a single prefix per pod, which is what routing supports, resource already comes out right two ways: leave PROXY_BASE_URL unset and let SERVER_ROOT_PATH flow through request.base_url, or set PROXY_BASE_URL=https://mcp.example.com/tenant-a, since _resolve_proxy_base_url_env preserves the path component. If your ingress genuinely preserves distinct prefixes into one pod, the missing piece is per-request root_path, not the discovery builders, and that is a much larger change worth its own issue

Happy to be wrong on the routing point if you have a deployment where the prefixed .well-known request actually reaches the handler; that would change the picture

Hey @tin-berri

You're correct — my approach here was incorrect. Thanks for outlining the routing reality, tin, that saves us going further down this shape.

The issue we're having. In our environment, distinct client-visible URL path prefixes route into the same LiteLLM pod from the ingress layer — e.g., /tenant-a/* and /tenant-b/* both terminating at the same pod. Discovery today derives every URL from PROXY_BASE_URL, which is a scalar per pod and can encode at most one prefix. One pod = one prefix that discovery renders correctly for; any second origin's client sees a resource value that doesn't match the URL it called and aborts on the RFC 9728 §3 exact-match check.

What we want to achieve. One LiteLLM pod fronting multiple OAuth pass-through MCP origins on distinct URL path prefixes, each origin's discovery emitting a resource that matches the URL its client called.

Why. Shared-pod economics. One Deployment to version, patch, and monitor rather than N. One replica floor rather than N × baseline. One config surface, one connection pool, one Redis. Adding a new origin becomes a config change rather than a new Kubernetes resource cohort (VirtualService + AuthorizationPolicy + WAF rule + Deployment + HPA + PDB). Faster onboarding, less drift risk between per-origin releases.

Would you mind pointing me in the right direction to make this work upstream — or, if it'd unblock us, letting me raise a PR toward it? If per-request root_path is the primitive I should be targeting, rough shape of what I'd propose:

Opt-in, off by default. Existing scalar SERVER_ROOT_PATH and path-carrying PROXY_BASE_URL paths untouched.

Configured list of allowed prefixes (in the config file). LiteLLM matches the incoming path against the list, strips the matched prefix off scope["path"], populates scope["root_path"] for that request only.

Routes stay registered root-relative. Every downstream URL constructor picks up the per-request prefix via request.base_url — no discovery-builder changes needed.

404 for any prefix not in the allowed list.

Can you confirm — if I raise a PR in this shape, would that be acceptable? Happy to iterate on the mechanism (config-file vs. env vs. request-header driving the strip) if you'd prefer a different one, or to hear "keep the single-scalar model, push the multi-origin discovery rewrite out to the mesh" if that's the upstream preference. Also, if there's a config shape I've missed where the current single-scalar mechanism can actually serve multiple prefixes into one pod today, I'd want to know first — that would remove the need for the change entirely.

Happy to close this PR and follow up on a fresh issue with a written-up proposal, plus the implementation PR after we align. #35576 covers the reproducible sub-path bug we hit and resolves the immediate breakage regardless.

@gym-cmd

gym-cmd commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up as promised: the per-request root_path implementation is up as #35935 (draft, targeting litellm_internal_staging).

It is exactly the shape from the comment above / the direction you pointed at: an opt-in SERVER_ROOT_PATHS allowed-prefix list, an outermost ASGI middleware that resolves scope["root_path"] per request on a segment boundary, routes staying registered root-relative, and the discovery builders untouched — request.base_url carries the prefix into every emitted URL. Unmatched prefixes 404, and with the env unset the middleware is not added at all.

Live before/after from a real proxy is in the PR body: one pod, /tenant-a + /tenant-b, each prefix's 401 challenge and discovery document carrying its own prefix with resource equal to the URL the client called (RFC 9728 §3), unprefixed spellings byte-identical to today. It composes with — but does not depend on — #35576.

@tin-berri @milan-berri whenever you get a chance 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants