Skip to content

feat(mcp): relay upstream 401 on client-forwarded pass-through tool calls - #32556

Merged
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_passthrough_call_relay
Jul 10, 2026
Merged

feat(mcp): relay upstream 401 on client-forwarded pass-through tool calls#32556
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_passthrough_call_relay

Conversation

@tin-berri

@tin-berri tin-berri commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Pre-Submission checklist

  • 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

Screenshots / Proof of Fix

Verified live on a local proxy running this branch, against a stub upstream MCP server on :9200 that returns 200 for a valid bearer, 401 (with an RFC 9728 resource_metadata challenge) for EXPIRED, 403 for FORBIDDEN, and 503 for TRIGGER503, and logs every Authorization it receives so we can prove which token the proxy forwarded. A true_passthrough server relay_pt points at it; the caller's upstream token rides the per-server x-mcp-relay_pt-authorization header, never confused with the gateway admission key. Every case is a tool CALL through POST /mcp-rest/tools/call

### 0. Register a true_passthrough server -> stub upstream :9200
server_id=6a25d747-bbad-4548-b832-f92d3bddf542

### 1. tools/list with a VALID token -> populate discovery
  tools/list -> HTTP 200   (tool name = relay-A_echo)

### 2. tools/call, VALID token -> 200, upstream receives it verbatim
  [SUCCESS] token=VALID-TOKEN -> HTTP 200
      body: isError=False
      upstream saw: authorization=Bearer VALID-TOKEN

### 3. tools/call, EXPIRED token -> upstream 401 RELAYED with its WWW-Authenticate (not a masked isError)
  [RELAY_401] token=EXPIRED -> HTTP 401
      relayed challenge: www-authenticate: Bearer resource_metadata="http://localhost:9200/.well-known/oauth-protected-resource", error="invalid_token"
      body: isError=None

### 4. tools/call, FORBIDDEN token -> upstream 403 is NOT a re-auth signal -> graceful isError (not relayed)
  [NONAUTH_403] token=FORBIDDEN -> HTTP 200
      body: isError=True

### 5. tools/call, genuine 503 -> graceful isError (not relayed as a challenge)
  [NONAUTH_503] token=TRIGGER503 -> HTTP 200
      body: isError=True

The success call forwards the caller's token byte-for-byte; an upstream 401 becomes a real 401 with the upstream WWW-Authenticate preserved (so a standards-compliant MCP client re-runs the upstream OAuth flow, instead of the pre-change masked HTTP 200 {"isError":true,"content":[{"text":"HTTPStatusError: Client error '401 Unauthorized' ..."}]}). Only 401 is a re-auth signal; a 403 is a genuine authorization failure that re-authorizing will not fix, so like a 503 it keeps the graceful isError degradation rather than being mistaken for a challenge

Logging, checked in the same run: the expected re-auth 401 must not trip operator error-rate alerts, while a genuine failure stays visible

### proxy log levels for the three failing calls above
  ERROR-level lines on the relay/call paths:                          count=0
  INFO 'relaying upstream HTTP 401' (the expected re-auth):           count=1
  WARNING 'Pass-through MCP tool call failed ... (non-auth ...' (403 + 503):   count=2
  run_with_session WARNING lines (quieted under raise_on_error):      count=0

### the two WARNING lines, verbatim (exception type only; no url, no str(e))
  LiteLLM:WARNING: mcp_server_manager.py - Pass-through MCP tool call failed against relay_pt (non-auth, HTTPStatusError)
  LiteLLM:WARNING: mcp_server_manager.py - Pass-through MCP tool call failed against relay_pt (non-auth, HTTPStatusError)

The relayed 401 produces zero error-level and zero warning-level lines (it logs at info at the endpoint, and at debug at the client layer since the caller owns the exception under raise_on_error; the shared run_with_session helper is quieted the same way, so no stray warning per call). Only the genuine non-auth 403 and 503 emit a WARNING, one each, and that line carries the exception type only, never str(e), which for an httpx error would embed the upstream URL a credential can hide in

Type

🐛 Bug Fix

Changes

A true_passthrough / oauth_delegate tool call carries the caller's own upstream token, so an upstream 401 on the call is the caller's to resolve. The single-server REST call path masked it as a generic isError tool result, so a standards-compliant MCP client never saw the challenge and could not re-run the upstream OAuth flow; the multi-server list path already relayed it

_call_regular_mcp_tool now, for the client-forwarded modes only, opts into raise_on_error and turns an upstream 401 into MCPUpstreamAuthError with the upstream WWW-Authenticate preserved. Only 401 is treated as a re-auth signal; a 403 is a genuine authorization failure that re-auth will not fix, so it takes the same non-auth branch as any transport error and keeps the default isError degradation via error_tool_result behind a visible warning. Every other auth type (api_key, M2M, OBO with its own 401 retry) is left untouched, and the legacy oauth2 + delegate mode being removed is deliberately not added to the call-path relay, so the change cannot regress them

The REST call endpoint relays that into a real 401 with the challenge preserved, mirroring the existing tools/list behavior. A single except MCPUpstreamAuthError on the endpoint covers both the direct execute_mcp_tool call and the virtual mcp_tool_call branch, so neither can fall through the catch-all into a generic 500; locally generated permission denials (tool, server, IP filtering) raise a plain HTTPException and keep their error-level logging, since only the typed upstream-auth relay is demoted to info. The streamable-HTTP protocol path cannot emit a raw 401 mid-session, because the MCP session manager serializes handler exceptions as JSON-RPC errors (the same reason the connect-time 401 is done preemptively), so it returns an explicit isError naming the upstream status instead of a masked 500 or a logged traceback, and call_mcp_tool re-raises MCPUpstreamAuthError before its generic handler so the expected re-auth does not fire post_call_failure_hook and its LLM-exception alert

An expected pass-through re-auth is not an operator-actionable error, so it is kept off the error and warning levels end to end: info at the endpoint and the streamable handler, and debug at the client layer, where MCPClient.call_tool / list_tools and the run_with_session helper they share all demote their failure line when the caller opted into raise_on_error and therefore owns the exception. The relay's non-auth branch still logs a warning before it degrades a genuine upstream outage to isError, so a real failure stays visible while normal re-authentication no longer trips error-rate alerts


Note

Medium Risk
Touches MCP proxy auth relay and tool-call error paths for pass-through modes only, but changes HTTP status and logging behavior that clients and alerts depend on.

Overview
Client-forwarded pass-through tool calls (true_passthrough / oauth_delegate) now surface an upstream HTTP 401 as MCPUpstreamAuthError with preserved WWW-Authenticate, instead of masking it as a generic isError tool result. Only 401 triggers re-auth relay; 403 and other failures still degrade to isError with a warning.

On REST POST /mcp-rest/tools/call, that exception becomes a real 401 + challenge (direct and virtual mcp_tool_call paths). On the streamable MCP session path, handlers return an explicit isError naming the upstream status, and call_mcp_tool skips post_call_failure_hook for this case.

Logging: expected re-auth is info/debug (not error/warning) via quiet_on_error on run_with_session and demoted logs in call_tool / list_tools when raise_on_error=True; genuine non-auth failures stay visible.

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

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.44444% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/experimental_mcp_client/client.py 75.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR relays upstream HTTP 401s from client-forwarded (true_passthrough / oauth_delegate) tool calls as real 401 responses with the upstream WWW-Authenticate preserved, so standards-compliant MCP clients can re-run the upstream OAuth flow rather than receiving a masked isError result. A 403 (authenticated but forbidden; re-auth won't help) is correctly kept as a graceful isError with a warning, and all other auth modes are untouched.

  • Manager (_call_regular_mcp_tool): opts into raise_on_error for the two client-forwarded modes, converts a 401 to MCPUpstreamAuthError, and routes every other failure to error_tool_result with a warning — 403 is explicitly excluded from the relay.
  • REST endpoint (call_tool_rest_api): a single except MCPUpstreamAuthError covers both the direct and virtual call branches, relays at info level, and keeps locally generated HTTPExceptions at error level so restriction-probing stays monitorable.
  • Streamable-HTTP path (call_mcp_tool / mcp_server_tool_call): re-raises MCPUpstreamAuthError before post_call_failure_hook (avoiding false LLM-exception alerts) and converts it to an informational isError for the client.
  • Client layer (call_tool, list_tools, run_with_session): demotes failure logging to debug under raise_on_error so an expected re-auth 401 emits zero error or warning lines end-to-end.

Confidence Score: 5/5

Safe to merge; changes are tightly scoped to the two client-forwarded auth modes and leave all other auth paths untouched.

The relay logic is correct and consistent with the existing list-path behavior: only HTTP 401 is treated as a re-auth signal, 403 and transport errors remain as graceful isError results. Logging demotion is end-to-end (client layer, manager, REST endpoint, streamable handler) and verified by mutation-checked mock tests. The exception ordering in rest_endpoints.py ensures the upstream-auth catch is handled before the generic HTTPException catch, so the relayed 401 is never double-logged at error level. All new test code uses mocks only, with no real network calls.

No files require special attention. The one test parametrization that covers an unreachable manager state is a clarity concern only and does not affect runtime behavior.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Adds upstream 401 relay for true_passthrough and oauth_delegate modes in _call_regular_mcp_tool; only 401 becomes MCPUpstreamAuthError, 403/5xx stay as graceful isError with a warning.
litellm/proxy/_experimental/mcp_server/rest_endpoints.py Adds a single except MCPUpstreamAuthError handler covering both the direct and virtual tool-call branches; locally generated HTTPExceptions keep error-level logging; virtual branch extracted to _handle_virtual_mcp_tool.
litellm/proxy/_experimental/mcp_server/server.py Two targeted additions: MCPUpstreamAuthError is re-raised in call_mcp_tool without post_call_failure_hook, and converted to an informational isError in mcp_server_tool_call for the streamable-HTTP path.
litellm/experimental_mcp_client/client.py Adds quiet_on_error to run_with_session and demotes logging to debug in call_tool/list_tools under raise_on_error, so an expected pass-through 401 re-auth does not emit error-level lines.
tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py New tests cover relay, local-denial error-level retention, and virtual-branch relay; the test_call_tool_rest_relays_upstream_auth_failure parametrization with upstream_status=403 tests a state the manager cannot currently produce.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py Adds mutation-checked tests covering 401 relay, tool-result passthrough, non-auth 403/503 isError degradation, and non-passthrough mode isolation; all use mocks only.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py Adds tests verifying MCPUpstreamAuthError becomes an informational isError in mcp_server_tool_call and that call_mcp_tool skips post_call_failure_hook for expected re-auth.
tests/test_litellm/experimental_mcp_client/test_mcp_client.py Adds three new tests validating debug-vs-error log demotion for raise_on_error in call_tool, list_tools, and run_with_session; existing tests reformatted only.

Reviews (3): Last reviewed commit: "feat(mcp): relay upstream 401 on client-..." | Re-trigger Greptile

@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_ui_enum branch from bd2bc06 to a0c3bfd Compare July 9, 2026 00:19
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch from ac53c31 to 1f173f0 Compare July 9, 2026 00:20
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_ui_enum branch from a0c3bfd to df0e855 Compare July 9, 2026 00:23
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch 4 times, most recently from 22a9a3e to e5daf8b Compare July 9, 2026 06:09
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_ui_enum branch from 9a33c2d to 454fde6 Compare July 9, 2026 16:16
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch from e5daf8b to b4db640 Compare July 9, 2026 16:16
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_ui_enum branch from 454fde6 to 2a5a9bd Compare July 9, 2026 18:04
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch from b4db640 to a07c158 Compare July 9, 2026 18:11
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_ui_enum branch from 2a5a9bd to a3f1873 Compare July 9, 2026 18:41
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch 3 times, most recently from 29c86ca to 60443db Compare July 9, 2026 20:40
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_ui_enum branch from 46a4d6f to bff2c95 Compare July 9, 2026 20:42
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch 2 times, most recently from 29c86ca to 7542b1a Compare July 9, 2026 20:51
Comment thread litellm/proxy/_experimental/mcp_server/rest_endpoints.py Outdated
@veria-ai

veria-ai Bot commented Jul 9, 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: 1 · PR risk: 0/10

@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch 2 times, most recently from a8d9e63 to bc99ae6 Compare July 9, 2026 22:35
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_ui_enum branch from 02157d7 to d4e02ac Compare July 9, 2026 22:42
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch from bc99ae6 to 1e224a4 Compare July 9, 2026 22:42
Base automatically changed from litellm_mcp_passthrough_ui_enum to litellm_internal_staging July 9, 2026 23:12
@codspeed-hq

codspeed-hq Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_mcp_passthrough_call_relay (e33654b) with litellm_internal_staging (bf02a4a)

Open in CodSpeed

@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch from 705f939 to 25eb550 Compare July 10, 2026 01:28
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai rereview

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/_experimental/mcp_server/rest_endpoints.py Outdated
Comment thread litellm/proxy/_experimental/mcp_server/server.py
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch from b7b1521 to e506eb5 Compare July 10, 2026 16:56
…alls

The multi-server list path already relays an upstream 401 from a client-forwarded
server (true_passthrough / oauth_delegate) as an MCPUpstreamAuthError so the caller
re-runs its own upstream OAuth. The single-server REST call path did not: an upstream
401 was masked as a graceful isError result, so an MCP client holding an expired
upstream token never learned it had to re-authenticate

Relay the upstream 401 on the call path too. For these modes the manager calls the
client with raise_on_error=True, extracts the WWW-Authenticate through the existing
upstream-auth exception walk, and raises MCPUpstreamAuthError; the REST endpoint turns
it into a real 401 + WWW-Authenticate. Only 401 is treated as a re-auth signal (a 403 is
a genuine authorization failure that re-auth will not fix, so it stays a masked isError
with a visible warning), matching the list path and MCPUpstreamAuthError's contract. The
legacy oauth2 + delegate_auth_to_upstream mode is deliberately left off the call-path
relay since it is being removed

To keep this expected caller-must-reauth signal from tripping error-rate alerts, the
client layer logs at debug when the caller opted into raise_on_error and therefore owns
the exception (both call_tool/list_tools and the run_with_session helper they share, so an
expected re-auth emits no warning per call either), the manager's non-auth branch logs the
exception type only (never str(e), which for an httpx error embeds the upstream URL a
credential can hide in), and the streamable and REST handlers log the relayed 401 at info
rather than as an error with a traceback

Tests cover the manager raising on a client-forwarded 401 while keeping a 403/503 as a
masked isError, the client-layer debug-vs-error logging split, the streamable handler's
informational isError, and the REST endpoint relaying both the direct and virtual
mcp_tool_call branches as a real 401 + WWW-Authenticate; each was mutation-checked to fail
when the corresponding behavior is broken
@tin-berri
tin-berri force-pushed the litellm_mcp_passthrough_call_relay branch from e506eb5 to e33654b Compare July 10, 2026 17:09
@tin-berri tin-berri changed the title feat(mcp): relay upstream 401/403 on client-forwarded pass-through tool calls feat(mcp): relay upstream 401 on client-forwarded pass-through tool calls Jul 10, 2026
@tin-berri

Copy link
Copy Markdown
Contributor Author

Pushed a refactor and re-verified live, so the tree is different from the last review. Summary of what changed since then

The REST relay no longer uses a marker HTTPException subclass or a per-call wrapper. A single except MCPUpstreamAuthError on the endpoint now covers both the direct execute_mcp_tool call and the virtual mcp_tool_call branch (they share one try), and the log level keys on that exception type: the typed upstream-auth relay is demoted to info while locally generated permission denials raise a plain HTTPException and keep error-level logging

The call path now treats only 401 as a re-auth signal. A 403 is a genuine authorization failure that re-auth will not fix, so it takes the same non-auth branch as a transport error and stays a masked isError behind a visible warning, matching the list path and the MCPUpstreamAuthError contract. The non-auth warning logs the exception type only, never str(e), which for an httpx error embeds the upstream URL a credential can hide in

Client-layer logging is synchronized so an expected re-auth is quiet end to end: call_tool, list_tools, and the run_with_session helper they share all demote their failure line to debug under raise_on_error, so neither tools/list nor tools/call emits an error or a stray warning per call for the expected signal

Every new and strengthened test was mutation-checked to fail when the behavior it guards is broken. The Proof of Fix in the description is a fresh live run against a stub upstream on this branch showing the 401 relayed with its WWW-Authenticate, the 403 and 503 kept as graceful isError, and zero error-level and zero warning-level lines for the relayed 401

@greptileai

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@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 e33654b. Configure here.

@mateo-berri mateo-berri 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.

LGTM; thanks!

@tin-berri
tin-berri merged commit b9008cc into litellm_internal_staging Jul 10, 2026
132 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_passthrough_call_relay branch July 10, 2026 18:56
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