Skip to content

fix(mcp): surface tools/list auth failures as a 401 challenge on single-server routes - #31921

Merged
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_surface_auth_challenge
Jul 3, 2026
Merged

fix(mcp): surface tools/list auth failures as a 401 challenge on single-server routes#31921
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_surface_auth_challenge

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

N/A

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 requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Local proxy on :4010 with two config MCP servers, both auth_type: none: linear_unauth points at an OAuth-protected remote MCP server that returns 401 for an unauthenticated request, and deepwiki_healthy points at a public no-auth MCP server that lists tools. Launched on the unfixed commit, curled, then on this branch and curled again.

Before (unfixed): the single-server route hides the auth failure as a 200 with an empty list

$ curl -s -D - -H 'Authorization: Bearer sk-1234' \
    'http://localhost:4010/mcp-rest/tools/list?server_id=linear_unauth'
HTTP/1.1 200 OK
{"tools":[],"error":null,"message":"Successfully retrieved tools"}

After (this branch): the single-server route returns 401 with the upstream WWW-Authenticate challenge

$ curl -s -D - -H 'Authorization: Bearer sk-1234' \
    'http://localhost:4010/mcp-rest/tools/list?server_id=linear_unauth'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="OAuth", resource_metadata="https://mcp.linear.app/.well-known/oauth-protected-resource/mcp", error="invalid_token", error_description="Missing or invalid access token"
{"detail":"Unauthorized"}

The multi-server aggregate listing degrades the unauthenticated server to an empty contribution and still returns the healthy server's tools, identically before and after (200, three tools, only deepwiki_healthy represented)

$ curl -s -H 'Authorization: Bearer sk-1234' 'http://localhost:4010/mcp-rest/tools/list'
HTTP 200
tools: 3 | servers: ['deepwiki_healthy'] | error: None

Type

🐛 Bug Fix

Changes

A 401 while listing MCP tools was swallowed to an empty tool list, so a single-server client saw a 200 with no tools and no WWW-Authenticate challenge instead of a 401 it could re-authenticate against. _fetch_tools_with_timeout only converted an upstream 401 into MCPUpstreamAuthError when the server was oauth pass-through or a delegate-to-upstream oauth2 server; every other auth_type returned []. Separately, the missing or expired per-user OAuth token surfaces during client creation as a bare HTTPException(401) carrying a WWW-Authenticate header, which _get_tools_from_server caught in its except Exception: return [], so that case masked for every mode

This makes the surface-vs-absorb decision key on the route rather than the auth_type. An upstream 401 now becomes MCPUpstreamAuthError regardless of auth_type, and the per-user OAuth challenge is converted to the same type at the _get_tools_from_server boundary while non-auth errors still degrade to []. The challenge is scoped to 401: a 403 (authenticated but forbidden, for example insufficient scope) is not a re-auth signal, so it degrades to an empty list like any other non-auth error, and the stdio-allowlist 403 (which carries no challenge header) stays absorbed. The existing routing then behaves as intended without further change: the single-server REST and MCP-protocol routes turn MCPUpstreamAuthError into a 401 with the WWW-Authenticate challenge, while the multi-server aggregator keeps absorbing it to an empty list so one unauthenticated server does not fail the whole listing

The dashboard tools page consumes the new 401 for OBO (per-user authorization_code) servers: the Authorize gate now shows when the list call returns 401, not only when no credential row exists. The backend already refreshes a still-refreshable token on the list call, so a 401 there means there is no valid token and none could be minted (expired with no usable refresh token), which is exactly when the user must reauthorize

Regression coverage was added to the mapped tests. test_mcp_server_manager.py covers _fetch_tools_with_timeout raising on an upstream 401 and absorbing a 403, returning [] on a non-auth error, _get_tools_from_server converting the per-user token 401 challenge and absorbing a non-challenge 403, and the aggregate list_tools absorbing a failing server while still returning the healthy one. test_rest_endpoints.py covers the aggregate REST route degrading one server's auth failure. test_mcp_oauth_passthrough_tools.py was updated for the removed server= parameter and the always-raise-on-401 contract. mcp_tools.test.tsx covers the OBO tools page showing the Authorize gate on a 401 while a refreshable expired token still lists silently


Note

Medium Risk
Changes MCP proxy authentication error handling for all server auth types on tools/list; behavior shifts from silent empty lists to 401 challenges on single-server routes, with aggregate routes unchanged.

Overview
MCP tools/list no longer turns upstream auth failures into a 200 with an empty tool list on single-server paths. Upstream 401 is always raised as MCPUpstreamAuthError (dropping the old auth_type carve-out), while 403 and other errors still degrade to [].

_fetch_tools_with_timeout always calls list_tools(raise_on_error=True) and no longer takes a server argument. _get_tools_from_server maps client-creation HTTPException(401) with a WWW-Authenticate header into the same error type. Single-server REST/MCP routes can return 401 + challenge; multi-server aggregate listings keep absorbing one bad server.

The dashboard OBO tools view shows the Authorize gate when the list API returns 401, not only when no credential row exists (e.g. expired token with no refresh).

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

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a bug where upstream 401 auth failures on MCP tools/list were silently converted to a 200 with an empty tool list on single-server routes. The fix removes the auth_type-based carve-out and always raises MCPUpstreamAuthError on upstream 401, letting single-server REST/MCP routes surface the 401 + WWW-Authenticate challenge while the multi-server aggregator continues absorbing failures to an empty list.

  • _fetch_tools_with_timeout drops the server parameter and always calls list_tools(raise_on_error=True); only HTTP 401 (not 403) becomes MCPUpstreamAuthError — 403 and other errors still degrade to [].
  • _get_tools_from_server gains an HTTPException catch that converts a 401 with a WWW-Authenticate header (from _create_mcp_client, e.g. expired per-user OAuth token) into the same error type.
  • The dashboard OBO tools view now shows the Authorize gate when the list API returns 401, covering the expired-token-no-refresh case in addition to the missing-credential case.

Confidence Score: 5/5

Safe to merge. The behavioral change is intentional, well-scoped (single-server routes only), and regression-tested across both Python and TypeScript layers.

The routing contract — single-server routes raise, multi-server aggregator absorbs — is consistently enforced at every callsite and covered by the new test suite. The two test files that flip swallow behavior to raise accurately reflect the intentional removal of the auth_type carve-out rather than masking a regression.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Removes the auth_type carve-out from _fetch_tools_with_timeout (always raise_on_error=True; drops server param) and adds an HTTPException(401+WWW-Authenticate) → MCPUpstreamAuthError conversion at the _get_tools_from_server boundary. Logic is consistent with the routing contract: single-server routes re-raise, multi-server aggregator absorbs.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py Adds TestMCPToolsListAuthSurfacing with six focused tests covering 401 raise, 403 absorb, non-auth error absorb, HTTPException(401+challenge) conversion, non-challenge HTTPException absorb, and aggregate list absorption of a failing server.
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py Removes server= keyword argument from _fetch_tools_with_timeout calls (matches dropped parameter), and flips the two swallow-to-empty-list tests for client_credentials and gateway-managed servers to verify they now raise MCPUpstreamAuthError, correctly reflecting the intentional behavior change.
tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py Adds test_aggregate_list_absorbs_one_server_auth_failure; class already carries pytestmark = pytest.mark.asyncio so the new async test runs correctly. Patches _get_tools_for_single_server, which is the actual module-level function called in the aggregate loop.
ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx Adds oboTokenRejected derived from mcpToolsError.status === 401, wired into authGateActive and the OBO gate JSX. Works correctly with the existing queryFn that converts result.error + status:401 into a thrown Error with .status=401.
ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.test.tsx Adds a test for the expired-no-refresh OBO scenario: credential exists, list call returns {error, status:401}, gate is shown. Test correctly uses mockResolvedValue so the queryFn result.error path triggers the thrown error.

Reviews (4): Last reviewed commit: "fix(mcp): surface tools/list 401 auth fa..." | Re-trigger Greptile

Comment thread litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@greptile-apps

This comment was marked as outdated.

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

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tin-berri
tin-berri force-pushed the litellm_mcp_surface_auth_challenge branch from 7745f9e to 1a77917 Compare July 2, 2026 00:11
@tin-berri

Copy link
Copy Markdown
Contributor Author

Addressed the 403 consistency point. The only 403 raised during client creation is the stdio-command-allowlist rejection, which is not an auth challenge and should keep degrading to an empty list; blanket-converting 403 would surface it as a spurious challenge. The handler now gates on the presence of a WWW-Authenticate header, so it converts a 401 or 403 that actually carries a challenge and leaves the non-challenge stdio 403 absorbed. Added a regression test that a 403 without a challenge stays absorbed

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

The WWW-Authenticate-gating in the HTTPException handler is sound — a 403 without a challenge (e.g., the stdio allowlist rejection) stays absorbed while an actual OAuth challenge is surfaced. That distinction is correct.

However, there's an issue with the existing test_mcp_oauth_passthrough_tools.py tests that the PR did not update. The function signature change (removing server= from _fetch_tools_with_timeout) and the always-raise_on_error=True behavior will cause several tests in that file to fail:

  1. test_fetch_tools_from_passthrough_raises_on_upstream_401, test_fetch_tools_from_delegated_oauth2_raises_on_upstream_401, test_fetch_tools_from_client_credentials_oauth2_keeps_swallow_behavior, and test_fetch_tools_from_gateway_managed_swallows_errors all pass server=<MCPServer> as a keyword argument — that parameter no longer exists, so they'll TypeError immediately.

  2. test_fetch_tools_from_client_credentials_oauth2_keeps_swallow_behavior asserts mock_client.list_tools.assert_awaited_with(raise_on_error=False) and expects tools == [], but the upstream response in that test carries a WWW-Authenticate header — so _extract_upstream_auth_failure would now return a result, raising MCPUpstreamAuthError instead of swallowing.

  3. test_fetch_tools_from_gateway_managed_swallows_errors asserts raise_on_error=False, which is also now incorrect.

The three tests that assert the old raise_on_error=False / swallow behavior for specific auth_type carve-outs represent the design that was deliberately replaced here — they need to either be deleted (if the old per-auth-type distinction is fully gone) or updated to reflect the new contract (surface vs. absorb is now the caller's job, and _fetch_tools_with_timeout always raises MCPUpstreamAuthError when there's a WWW-Authenticate challenge). Otherwise CI will catch these failures.

@tin-berri

Copy link
Copy Markdown
Contributor Author

Updated: scoped the challenge to 401 only (a 403, including insufficient_scope, degrades to an empty list rather than prompting re-auth, since re-authenticating does not resolve a forbidden result); fixed the test_mcp_oauth_passthrough_tools.py tests for the removed server= param and the new always-raise contract; and added the UI half so an OBO server's tools page shows the Authorize gate on a 401, not only when no credential row exists.

@greptileai

@tin-berri
tin-berri force-pushed the litellm_mcp_surface_auth_challenge branch from 1a77917 to 6bf5877 Compare July 2, 2026 17:38
…le-server routes

A 401 while listing tools (a missing or expired per-user OAuth token, or an
upstream 401 for any auth_type) was swallowed to an empty tool list, so a
single-server client got a 200 with no tools and no WWW-Authenticate challenge
instead of a 401 it could re-authenticate against. Only oauth pass-through and
delegate-to-upstream oauth2 servers surfaced it; every other auth_type, and the
missing-token case for all of them, masked it.

The surface-vs-absorb decision now keys on the route, not the auth_type. An
upstream 401 in _fetch_tools_with_timeout becomes an MCPUpstreamAuthError
regardless of auth_type, and the per-user OAuth challenge raised during client
creation (a bare HTTPException 401 carrying a WWW-Authenticate header) is
converted to the same type in _get_tools_from_server. The challenge is scoped
to 401: a 403 (authenticated but forbidden, e.g. insufficient scope) is not a
re-auth signal and degrades to an empty list like any other non-auth error, and
the stdio-allowlist 403 (no challenge header) stays absorbed. The existing
routing then does the right thing: single-server routes turn the error into a
401 + WWW-Authenticate, while the multi-server aggregator absorbs it to an empty
list so one unauthenticated server does not fail the whole listing.

On the UI tools page, an OBO (per-user authorization_code) server now shows the
Authorize gate when the list call returns 401, not only when no credential row
exists. The backend already refreshes a still-refreshable token on the list
call, so a 401 means there is no valid token and none could be minted (expired
with no usable refresh token), which is exactly when the user must reauthorize.
@tin-berri
tin-berri force-pushed the litellm_mcp_surface_auth_challenge branch from 6bf5877 to 5810cf1 Compare July 2, 2026 18:22
@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 5810cf1. Configure here.

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@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 b9df7fa into litellm_internal_staging Jul 3, 2026
126 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_surface_auth_challenge branch July 3, 2026 01:05
yuneng-berri added a commit that referenced this pull request Jul 4, 2026
chore(release): backport #31912/#31920/#31921 (+#31923/#31929 parity, #31635 prereq) onto patch-1.91.0rc1
yuneng-berri added a commit that referenced this pull request Jul 4, 2026
chore(ui): rebuild Next.js bundle for #31921/#31920 on patch-1.91.0rc1
ap-anton-r-susilo pushed a commit to ap-anton-r-susilo/litellm that referenced this pull request Jul 6, 2026
…le-server routes (BerriAI#31921)

A 401 while listing tools (a missing or expired per-user OAuth token, or an
upstream 401 for any auth_type) was swallowed to an empty tool list, so a
single-server client got a 200 with no tools and no WWW-Authenticate challenge
instead of a 401 it could re-authenticate against. Only oauth pass-through and
delegate-to-upstream oauth2 servers surfaced it; every other auth_type, and the
missing-token case for all of them, masked it.

The surface-vs-absorb decision now keys on the route, not the auth_type. An
upstream 401 in _fetch_tools_with_timeout becomes an MCPUpstreamAuthError
regardless of auth_type, and the per-user OAuth challenge raised during client
creation (a bare HTTPException 401 carrying a WWW-Authenticate header) is
converted to the same type in _get_tools_from_server. The challenge is scoped
to 401: a 403 (authenticated but forbidden, e.g. insufficient scope) is not a
re-auth signal and degrades to an empty list like any other non-auth error, and
the stdio-allowlist 403 (no challenge header) stays absorbed. The existing
routing then does the right thing: single-server routes turn the error into a
401 + WWW-Authenticate, while the multi-server aggregator absorbs it to an empty
list so one unauthenticated server does not fail the whole listing.

On the UI tools page, an OBO (per-user authorization_code) server now shows the
Authorize gate when the list call returns 401, not only when no credential row
exists. The backend already refreshes a still-refreshable token on the list
call, so a 401 means there is no valid token and none could be minted (expired
with no usable refresh token), which is exactly when the user must reauthorize.

(cherry picked from commit b9df7fa)
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