Skip to content

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

Open
tin-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_mcp_oauth_challenge_root_path
Open

fix(mcp): strip root_path before matching the per-server MCP route spelling#35576
tin-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_mcp_oauth_challenge_root_path

Conversation

@tin-berri

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

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
  • Root-mounted deployments are unaffected; the strip is a no-op 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

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

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"], ...}

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

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 -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)

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

…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`
@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 (c033199) with litellm_internal_staging (ba480a6)

Open in CodSpeed

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.

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.

2 participants