Skip to content

fix(mcp): strip root_path before matching the per-server MCP route spelling - #35576

Merged
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_mcp_oauth_challenge_root_path
Aug 21, 2026
Merged

fix(mcp): strip root_path before matching the per-server MCP route spelling#35576
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_mcp_oauth_challenge_root_path

Conversation

@tin-berri

@tin-berri tin-berri commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • The per-server MCP 401 challenge decides which URL spelling the client used by matching _original_path against the root-relative /{server}/mcp shape
  • _original_path (and scope["path"]) are raw request-line paths, so on a SERVER_ROOT_PATH deployment they still carry the prefix and that match always fails
  • A client connecting on /litellm/{server}/mcp is therefore pointed at the standard-pattern discovery document, whose resource is {base}/litellm/mcp/{server} rather than the URL it called, and a strict RFC 9728 section 3 client aborts before the MCP request fires

The same raw-path assumption appears twice, in two different challenge shapes:

  1. the pass-through resource_metadata challenge in oauth_utils.py
  2. the gateway-managed authorization_code authorization_uri challenge in server.py, which additionally hardcoded /.well-known/oauth-authorization-server with no root-path segment, so the URL it advertised 404'd under a sub-path deployment regardless of which spelling branch was taken

How it solves it:

  • Removes root_path from the path before the spelling match, on a segment boundary, the same way litellm.proxy.auth.auth_utils.get_request_route already does for the rest of the MCP auth path
  • Routes the gateway-managed AS-metadata root through well_known_root_suffix(), the same helper the discovery route registrations derive their paths from, so the advertised URL cannot drift from the route that serves it
  • Root-mounted deployments are unaffected; both helpers are no-ops when there is no root_path

Relevant issues

  • Fixes the per-server MCP OAuth 401 challenge advertising the wrong protected-resource document on a SERVER_ROOT_PATH (sub-path) deployment
  • Restores the RFC 9728 section 3 exact-match property: the resource a client discovers now equals the MCP URL it connected to, in both the /{server}/mcp and /mcp/{server} spellings
  • Adds a regression test that pins both spellings under SERVER_ROOT_PATH through the real process_mcp_request caller
  • Fixes the same class of bug in the gateway-managed authorization_code challenge (raised in review by @Sanjays2402), covered by TestPreemptive401ModeAware::test_gateway_as_metadata_challenge_under_server_root_path

Surfaced by the discussion on #35226, which reported the same class of resource mismatch. That PR proposes a new opt-in env var to derive the discovery path from the request; this change instead fixes the root-path normalization the existing code already relies on, which covers the sub-path deployment without new configuration

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Pass-through resource_metadata challenge

Live proxy on a sub-path deployment, config below, started with

SERVER_ROOT_PATH=/litellm LITELLM_MASTER_KEY=sk-1234 \
  python litellm/proxy/proxy_cli.py --config mcp_rootpath_config.yaml --port 4111
mcp_servers:
  github:
    url: "https://upstream.example/mcp"
    transport: "http"
    auth_type: "oauth2"
    oauth2_flow: "authorization_code"
    client_id: "gateway-managed-client"
    client_secret: "gateway-managed-secret"
    authorization_url: "https://upstream.example/oauth/authorize"
    token_url: "https://upstream.example/oauth/token"

Before (this branch's base, ba480a619f) both spellings collapse onto the standard-pattern document:

$ for path in /litellm/github/mcp /litellm/mcp/github; do
    curl -s -i -X POST "http://localhost:4111${path}" \
      -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -iE '^HTTP/|^www-authenticate'
  done

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="http://localhost:4111/litellm/.well-known/oauth-protected-resource/litellm/mcp/github"
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="http://localhost:4111/litellm/.well-known/oauth-protected-resource/litellm/mcp/github"

Following the challenge a /litellm/github/mcp client was sent to shows the mismatch it aborts on:

$ curl -s "http://localhost:4111/litellm/.well-known/oauth-protected-resource/litellm/mcp/github"
{"resource": "http://localhost:4111/litellm/mcp/github", ...}

After (this branch) each spelling keeps its own document:

$ for path in /litellm/github/mcp /litellm/mcp/github; do
    curl -s -i -X POST "http://localhost:4111${path}" \
      -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -iE '^HTTP/|^www-authenticate'
  done

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="http://localhost:4111/litellm/.well-known/oauth-protected-resource/litellm/github/mcp"
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="http://localhost:4111/litellm/.well-known/oauth-protected-resource/litellm/mcp/github"

and each document's resource is exactly the URL the client connected to:

$ curl -s "http://localhost:4111/litellm/.well-known/oauth-protected-resource/litellm/github/mcp"
{"resource": "http://localhost:4111/litellm/github/mcp", "authorization_servers": ["http://localhost:4111/litellm/mcp"], ...}

$ curl -s "http://localhost:4111/litellm/.well-known/oauth-protected-resource/litellm/mcp/github"
{"resource": "http://localhost:4111/litellm/mcp/github", "authorization_servers": ["http://localhost:4111/litellm/mcp"], ...}

Gateway-managed authorization_code challenge (the second fix site)

This challenge only fires for a caller that authenticated to the proxy but has no interactive gateway session yet, so the repro needs a virtual key rather than an anonymous request. Same sub-path deployment, server named interactive, same oauth2 / authorization_code block as the config above

$ SERVER_ROOT_PATH=/litellm LITELLM_MASTER_KEY=sk-1234 \
    python litellm/proxy/proxy_cli.py --config mcp_authcode_rootpath_config.yaml --port 4111

$ KEY=$(curl -s -X POST http://localhost:4111/litellm/key/generate \
    -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' \
    -d '{}' | jq -r .key)

Before (parent commit e3869926f7) the advertised authorization-server document has no root-path segment, so following it 404s:

$ curl -s -i -X POST "http://localhost:4111/litellm/interactive/mcp" \
    -H "Authorization: Bearer $KEY" \
    -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -iE '^HTTP/|^www-authenticate'

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer authorization_uri="http://localhost:4111/litellm/.well-known/oauth-authorization-server/interactive"

$ curl -s -o /dev/null -w '%{http_code}\n' \
    "http://localhost:4111/litellm/.well-known/oauth-authorization-server/interactive"
404

After (this branch) the challenge points at a route the proxy actually registered:

$ curl -s -i -X POST "http://localhost:4111/litellm/interactive/mcp" \
    -H "Authorization: Bearer $KEY" \
    -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -iE '^HTTP/|^www-authenticate'

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer authorization_uri="http://localhost:4111/litellm/.well-known/oauth-authorization-server/litellm/interactive"

$ curl -s "http://localhost:4111/litellm/.well-known/oauth-authorization-server/litellm/interactive"
{"issuer":"http://localhost:4111/litellm/interactive","authorization_endpoint":"http://localhost:4111/litellm/interactive/authorize","token_endpoint":"http://localhost:4111/litellm/interactive/token","response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"], ...}

The /litellm/mcp/{server} spelling cannot be shown end to end here because it never reaches this branch under a sub-path deployment: _get_mcp_servers_in_path splits the raw request path, reads the root segment as the server name, and the request resolves to no server and returns an empty tool list. That is a separate pre-existing bug and is left alone in this PR; the spelling selection itself is pinned by the parametrized regression test

Type

🐛 Bug Fix

Changes

oauth_utils.py gains get_route_relative_request_path, which reads _original_path (falling back to scope["path"]) and removes the deployment's root_path when the raw path is that prefix or continues past it on a / boundary, so /litellmfoo is not truncated under root_path=/litellm. get_passthrough_resource_metadata_url now compares that normalized path instead of the raw one

Nothing else changes. On a root-mounted proxy root_path is empty and the helper returns the raw path unchanged, so the emitted metadata URL is byte-identical to today's

The regression test lives in the existing TestAggregateGatewayDcrChallenge class next to the non-root-path spelling test it mirrors, and drives the real process_mcp_request entry point rather than the helper, so it fails if either the challenge or the spelling selection regresses. It reverts to the pre-fix assertion failure when the normalization is removed

server.py's gateway-managed authorization_code branch gets the same treatment: _path now comes from get_route_relative_request_path(scope) instead of a raw scope["_original_path"] read, and the well-known root is built with well_known_root_suffix() so the advertised AS-metadata URL resolves to a registered route. Its regression test lives in TestPreemptive401ModeAware, is parametrized over both spellings, and drives process_mcp_request the same way

Both halves of the server.py fix are mutation-tested: reverting either the path normalization or the root suffix on its own fails the new test. The root suffix half is also reproduced against a live proxy in the proof section above

QA runbook

  1. uv run --no-sync pytest tests/test_litellm/proxy/_experimental/mcp_server/auth/ tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py -q
  2. Write the mcp_servers config above to mcp_rootpath_config.yaml
  3. SERVER_ROOT_PATH=/litellm LITELLM_MASTER_KEY=sk-1234 python litellm/proxy/proxy_cli.py --config mcp_rootpath_config.yaml --port 4111
  4. Run the two curl loops from the proof section and confirm the two spellings now advertise different resource_metadata URLs
  5. GET each advertised URL and confirm resource equals the MCP URL from step 4
  6. Restart with SERVER_ROOT_PATH unset and confirm the challenges are unchanged from before this PR (/.well-known/oauth-protected-resource/github/mcp and /.well-known/oauth-protected-resource/mcp/github)
  7. Add an interactive server with the same oauth2 / authorization_code block, restart under SERVER_ROOT_PATH=/litellm, and mint a virtual key with POST /litellm/key/generate
  8. POST /litellm/interactive/mcp with that key and confirm the authorization_uri in the 401 carries the /litellm root segment
  9. GET that URL and confirm it returns the authorization-server metadata instead of a 404

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Touches MCP OAuth 401 WWW-Authenticate URLs used by strict RFC 9728 clients; wrong URLs would break login, but the change is a path-normalization fix with regression tests and no-op on root-mounted proxies.

Overview
Fixes MCP OAuth 401 challenges on sub-path deployments (SERVER_ROOT_PATH). Previously, spelling detection used the raw request path, so /litellm/{server}/mcp never matched the root-relative /{server}/mcp shape and clients were sent to the wrong RFC 9728 metadata document (or a 404 AS-metadata URL).

Adds get_route_relative_request_path (segment-boundary strip, same idea as get_request_route) and uses it for both the pass-through resource_metadata challenge and the gateway-managed authorization_uri challenge. The latter also builds the well-known root via well_known_root_suffix() so advertised URLs match registered routes. Root-mounted proxies are unchanged.

Also catalogs Moonshot moonshot/kimi-k3 pricing.

Reviewed by Cursor Bugbot for commit 91ba899. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR normalizes raw MCP request paths against the deployment root path before selecting the per-server OAuth discovery spelling

  • Adds segment-boundary-aware root-path removal using the request scope
  • Adds regression coverage for both per-server route spellings under SERVER_ROOT_PATH

Confidence Score: 5/5

The PR appears safe to merge; the normalization matches the production scope rewrite and preserves both supported MCP route spellings

The helper strips only an exact root-path prefix or a prefix followed by a segment boundary, and the regression test exercises the real challenge-building caller for both affected route forms

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/oauth_utils.py Adds narrowly scoped root-path normalization before the existing route-spelling comparison, preserving root-mounted behavior and segment boundaries
tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py Adds focused regression coverage through process_mcp_request for both supported per-server spellings under a deployment root path

Reviews (1): Last reviewed commit: "fix(mcp): strip root_path before matchin..." | Re-trigger Greptile

@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_mcp_oauth_challenge_root_path (50b34ad) with litellm_internal_staging (04113aa)1

Open in CodSpeed

Footnotes

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 91ba899: the gateway-managed authorization_code branch now builds _path via get_route_relative_request_path(scope) instead of reading scope["_original_path"]/scope["path"] raw, and the AS-metadata root now goes through well_known_root_suffix() so the URL carries the same root-path segment the discovery routes are registered under. Added TestPreemptive401ModeAware::test_gateway_as_metadata_challenge_under_server_root_path, parametrized over both spellings, pinning the fix through the real process_mcp_request path (mutation-tested: reverting either half fails the new test).

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with score and why

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Score: 4.5 / 5

What this PR does well:

  1. Correct fix, not a workaround. get_route_relative_request_path mirrors the segment-boundary strip already in auth_utils.get_request_route, so the MCP auth path is now consistent end-to-end. The startswith(f"{root_path}/") guard correctly prevents /litellmfoo from being truncated under root_path=/litellm.

  2. Both halves of the bug are fixed. The passthrough resource_metadata challenge (oauth_utils.py) and the gateway-managed authorization_code challenge (server.py) both get the normalization, and the server.py path additionally routes through well_known_root_suffix() so the advertised AS-metadata URL can't drift from the registered route.

  3. Tests drive real entry points. Both new test classes call process_mcp_request / _raise_preemptive_401_for_unauthenticated_servers rather than the helper in isolation, so a regression in either the challenge or the spelling-selection logic will fail them.

  4. Root-mounted deployments are unaffected. When root_path is empty or /, both helpers return the raw path unchanged — the emitted URLs are byte-identical to pre-PR behavior.

Minor concerns:

  • moonshot/kimi-k3 model entry is unrelated to the fix. It's bundled into the same PR without mention in the title or the pre-submission checklist. It doesn't break anything, but mixing a model-pricing addition with an MCP OAuth bugfix makes the diff harder to bisect if either half introduces a regression.

  • app_root_path precedence over root_path. The helper checks scope.get("app_root_path") or scope.get("root_path"). If litellm explicitly sets app_root_path to a non-empty string different from the ASGI root_path, that's the right value to use — but it would be worth a brief inline comment explaining what app_root_path is and why it takes precedence, since it's not a standard ASGI scope key.

  • No test for the "no-op on root mount" case. The PR claims root-mounted behavior is unchanged, but neither test covers root_path="" / root_path="/". A quick parametrize case with no root_path in scope would make that guarantee explicit rather than asserted only in prose.

These are all minor. The core logic is sound, the proof-of-fix output is compelling, and the regression tests are structured correctly.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 91ba899. Configure here.

tin-berri and others added 2 commits August 21, 2026 14:21
…elling

The 401 challenge for a gateway-managed oauth2 MCP server advertises the
protected-resource metadata URL in the spelling the client connected on, so a
strict RFC 9728 section 3 client lands on a document whose `resource` equals the
URL it actually called. That spelling test compared `_original_path` against the
root-relative `/{server}/mcp` shape, but `_original_path` and `scope["path"]`
are raw request-line paths that still carry the deployment's `root_path`

On a SERVER_ROOT_PATH deployment the prefix therefore made the legacy test fail
and every request fell through to the standard `/mcp/{server}` branch. A client
connecting on `/litellm/github/mcp` was pointed at the standard-pattern
document, which serves `resource = {base}/litellm/mcp/github`; that is not the
URL the client called, so a strict client aborts discovery before the MCP
request fires

Route the path through `get_route_relative_request_path` first, which removes
`root_path` on a segment boundary the same way
`litellm.proxy.auth.auth_utils.get_request_route` already does for the rest of
the MCP auth path, so `/litellmfoo` is not truncated under `root_path=/litellm`
The gateway-managed authorization_code challenge in process_mcp_request
built its AS-metadata URL from two root-path-unaware pieces:

- it matched the caller's spelling against `scope["_original_path"]`, a
  raw request-line path that still carries the deployment prefix, so on a
  SERVER_ROOT_PATH deployment the `/mcp/{server}` branch never matched and
  every request fell through to the legacy one-segment form
- it hardcoded `/.well-known/oauth-authorization-server` without the
  root-path segment the discovery route decorators bake in, so the URL
  404'd under a sub-path deployment regardless of which branch was taken

Route the spelling match through get_route_relative_request_path and the
well-known root through well_known_root_suffix, the same two helpers the
discovery route registrations derive their paths from, so the advertised
URL cannot drift from the route that serves it.

Root-mounted deployments are unaffected: both helpers are no-ops when
SERVER_ROOT_PATH is unset.

Co-Authored-By: Claude <noreply@anthropic.com>
@tin-berri
tin-berri force-pushed the litellm_mcp_oauth_challenge_root_path branch from 91ba899 to 50b34ad Compare August 21, 2026 21:22
@tin-berri
tin-berri merged commit d193c7a into litellm_internal_staging Aug 21, 2026
72 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_oauth_challenge_root_path branch August 21, 2026 22:40
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