Skip to content

fix(mcp): keep upstream OAuth Authorization when jwt signer hook injects one on tools/call - #38555

Merged
yassin-berriai merged 2 commits into
litellm_internal_stagingfrom
litellm_fix_mcp_jwt_signer_oauth_tools_call
Aug 27, 2026
Merged

yassin-berriai merged 2 commits into
litellm_internal_stagingfrom
litellm_fix_mcp_jwt_signer_oauth_tools_call

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • MCP JWT signer overwrote the user's OAuth Authorization on tools/call
  • OAuth-backed MCP tools/call failed 401 while tools/list worked

How it solves it:

  • tools/call now mirrors the tools/list guard
  • A hook Authorization is dropped when an upstream credential already occupies the slot
  • Non-Authorization hook headers still merge; signer still works when no credential exists

User Flow

Before: a user whose gateway has the MCP JWT signer enabled can list tools on an OAuth-backed MCP server but every tool call is rejected upstream

  1. They connect their OAuth account for the MCP server, then GET https://litellm-domain/mcp-rest/tools/list?server_id=... with their LiteLLM key and see the server's tools
  2. They POST https://litellm-domain/mcp-rest/tools/call with the same key and a tool name
  3. The upstream server receives a LiteLLM-signed JWT instead of their OAuth token and returns 401 Unauthorized, so the call comes back with isError true

After: the same call succeeds because the gateway keeps their OAuth token in the Authorization header

  1. They connect their OAuth account, then GET https://litellm-domain/mcp-rest/tools/list?server_id=... and see the server's tools
  2. They POST https://litellm-domain/mcp-rest/tools/call with the same key and tool name
  3. The upstream server receives their own OAuth token and the tool result comes back with isError false

Relevant issues

Fixes #31977

Linear ticket

Resolves LIT-6321

Pre-Submission checklist

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

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • 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)

Screenshots / Proof of Fix

Root cause in one sentence: the tools/call path merged pre_mcp_call hook headers last, letting the signer's Authorization JWT replace the user or server OAuth credential, while tools/list already skipped signer injection when the Authorization slot was occupied

Setup shared by both runs: local proxy on :4000 with an oauth2 authorization_code MCP server (delegate_auth_to_upstream: true) pointing at an OAuth-protected MCP upstream on :9100 that accepts only Bearer USER_OAUTH_TOKEN_abc123 and logs the Authorization header it receives, plus the mcp_jwt_signer guardrail as a default_on pre_mcp_call hook. The calling key's user has that OAuth token stored as their per-user credential for the server. Requests are byte-identical across runs; the serving build is identified by a log line that exists only in the fixed code

Before (02dcc4d)

tools/list with OAuth credential and signer enabled (control, works)

  1. curl -s "http://localhost:4000/mcp-rest/tools/list?server_id=9ea9ca1cd1f9241dcfd1f7a23ba5bfd3" -H "x-litellm-api-key: Bearer $USER_KEY"
  2. Response: {"tools":[{"name":"whoami",...}],"error":null,"message":"Successfully retrieved tools"}
  3. Upstream log: UPSTREAM_AUTH_HEADER: Bearer USER_OAUTH_TOKEN_abc123

tools/call with OAuth credential and signer enabled (bug)

  1. curl -s -X POST "http://localhost:4000/mcp-rest/tools/call" -H "x-litellm-api-key: Bearer $USER_KEY" -H "Content-Type: application/json" -d '{"server_id":"9ea9ca1cd1f9241dcfd1f7a23ba5bfd3","name":"whoami","arguments":{}}'
  2. Response: {"_meta":null,"content":[{"type":"text","text":"HTTPStatusError: Client error '401 Unauthorized' for url 'http://127.0.0.1:9100/mcp'..."}],"structuredContent":null,"isError":true}
  3. Upstream log shows the signer JWT replaced the user's token: UPSTREAM_AUTH_HEADER: Bearer eyJhbGciOiJSUzI1NiIs... (decoded payload: iss=https://litellm.local aud=mcp-upstream act.sub=litellm-proxy)

After (3a91bd4)

tools/list with OAuth credential and signer enabled (control, still works)

  1. curl -s "http://localhost:4000/mcp-rest/tools/list?server_id=9ea9ca1cd1f9241dcfd1f7a23ba5bfd3" -H "x-litellm-api-key: Bearer $USER_KEY"
  2. Response: {"tools":[{"name":"whoami",...}],"error":null,"message":"Successfully retrieved tools"}

tools/call with OAuth credential and signer enabled (fixed)

  1. curl -s -X POST "http://localhost:4000/mcp-rest/tools/call" -H "x-litellm-api-key: Bearer $USER_KEY" -H "Content-Type: application/json" -d '{"server_id":"9ea9ca1cd1f9241dcfd1f7a23ba5bfd3","name":"whoami","arguments":{}}'
  2. Response: {"_meta":null,"content":[{"type":"text","text":"tool-ok","annotations":null,"_meta":null}],"structuredContent":{"result":"tool-ok"},"isError":false}
  3. Upstream log: UPSTREAM_AUTH_HEADER: Bearer USER_OAUTH_TOKEN_abc123
  4. Fixed-build marker: grep -c "dropping hook-injected 'Authorization'" proxy.log prints 1 (this warning line exists only in the fixed code)

Scope note: this run exercises the delegated per-user OAuth branch of the regular (SSE/HTTP) tools/call path. The same merge site also guards static_headers Authorization and configured Authorization-mapped authentication_token credentials, while api_key credentials (which map to X-API-Key) and per-server header dicts without Authorization do not block the signer; all covered by the new unit tests. Migrated (non-delegated) authorization_code, client_credentials, and token exchange servers already resolve their credential later and drop a conflicting hook JWT there; the OpenAPI-backed path ignores hook headers entirely and is unchanged

Type

🐛 Bug Fix

Caveats (if any)

Low

  • The signer JWT is silently dropped (with a warning log) instead of a typed config error when the slot is occupied, matching existing tools/list behavior

Link to Devin session: https://app.devin.ai/sessions/12cde377d365464f8379dd44698e3545
Open in Devin Desktop: https://app.devin.ai/desktop/session/12cde377d365464f8379dd44698e3545?variant=devin
Requested by: @yassin-berriai

…cts one on tools/call

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

PR #38555 (BerriAI/litellm, author devin-ai-integration[bot]) has no labels, so it lacks the enterprise label — out of scope. No GitHub or Linear changes made; no risk label applied and routing did not run.

@greptile-apps

greptile-apps Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

The latest revision refines tools/call header precedence so existing upstream Authorization credentials are preserved while non-conflicting signer headers continue to merge.

  • Detects Authorization occupancy case-insensitively across static and resolved server headers.
  • Keeps signer Authorization alongside api_key and non-Authorization dictionary credentials.
  • Adds regression coverage for OAuth, static headers, API keys, and per-server header dictionaries.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py The revised occupancy check now matches outbound credential mappings and resolves the previously reported non-Authorization credential conflict.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_hook_extra_headers.py Regression tests cover the corrected header-precedence cases without indicating a blocking behavioral failure.

Reviews (2): Last reviewed commit: "fix(mcp): only treat server credential a..." | Re-trigger Greptile

Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Outdated
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…n it maps to that header

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai review the latest revision (3a91bd4). Changes since the 3/5 review: the Authorization-occupancy check now inspects the resolved server credential — dict credentials are scanned for an actual Authorization key (case-insensitive) and api_key credentials (which map to X-API-Key) no longer block the hook-injected JWT; added regression tests for api_key credentials, per-server header dicts with and without Authorization, and tightened the test capture typing.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Addressed in 3a91bd4: capture dicts now use precise types instead of Dict[str, Any], with isinstance guards on the captured headers

@yassin-berriai
yassin-berriai merged commit e16aa9f into litellm_internal_staging Aug 27, 2026
79 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_fix_mcp_jwt_signer_oauth_tools_call branch August 27, 2026 19:44
@codspeed

codspeed Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_mcp_jwt_signer_oauth_tools_call (3a91bd4) with litellm_internal_staging (ca9007b)1

Open in CodSpeed

Footnotes

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

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.

[Bug]: MCP JWT signer overwrites OAuth Authorization header during tools/call

2 participants