Skip to content

fix(mcp): resolve admin OAuth sessions to the same server set the connect page shows - #37900

Merged
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_mcp_connect_page_parity
Aug 22, 2026
Merged

fix(mcp): resolve admin OAuth sessions to the same server set the connect page shows#37900
tin-berri merged 2 commits into
litellm_internal_stagingfrom
litellm_mcp_connect_page_parity

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • An admin who SSO's into the gateway during an MCP OAuth connect flow gets a session that resolves zero MCP servers unless they are explicitly granted each one, so Claude Code shows "connected" with no tools (silently on older builds, as an access_denied since fix(mcp): deny the interactive dcr_bridge authorize for a user without server access #37865) while the dashboard shows them every server. Reported by a customer on v1.96.2
  • The cause is two resolvers answering "which servers can this human reach": the dashboard list applies the admin view shortcut, and the OAuth-admitted subject path deliberately skips it (not is_admitted_subject in MCPServerManager.get_allowed_mcp_servers). The restriction is vacuous against an admin, who can self-grant or paste an admin virtual key and get the registry anyway, so it only produces the mismatch

How it solves it:

  • One predicate now owns the admin-view question for both credential shapes: MCPRequestHandler.admin_view_unscoped returns True for an admin-view role with no explicit object_permission.mcp_servers scope and no entitlement ceiling, and both the server-axis registry resolution and the tools-axis open channel call it, for keys and admitted session subjects alike
  • The registry result is seeded into the union instead of returned early, so the RFC 8707 session resource scope still bounds a per-server envelope held by an admin (the old early return would have widened it)
  • On the tools axis, admin view joins the existing open-channel arm riding the user's OWN source, so registry servers are invokable rather than listable but uninvokable. An admin whose user row carries any entitlement never reaches the channel because the ceiling clause disqualifies the predicate first
  • Non-admins are unchanged: their session already resolves their own connect-page set, and the fix(mcp): deny the interactive dcr_bridge authorize for a user without server access #37865 access_denied at authorize still fires for them. A key's explicit object_permission scope still wins even for admins, empty list included. An admitted subject's object_permission is the user's own row, whose mcp_servers column is [] by DB default, so for that shape the row binds through the entitlement ceiling instead (any non-empty entitlement disqualifies the registry seed), the same reading operator_open_server_ids already applies to that row
  • In-scope servers the gateway holds no upstream credential for already surface as {"status": "auth_required"} in the tools/list _meta server outcomes, so nothing on that axis needed code

This supersedes the design direction of open PR #34585, which locks UI sessions to the admitted-subject resolver with no admin registry path

Relevant issues

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

Rig: proxy from this branch on localhost:4612 with real Postgres, one stub upstream MCP server on :8933 that 401s without its bearer and counts authenticated requests, and a stub IdP on :8934. Two servers registered with allow_all_keys: false and zero grants: cust_bridge (oauth_delegate + dcr_bridge) and cust_oauth2 (oauth2, authorization_code). The change is on the auth path, so no LLM call is involved; the upstream MCP server is a local stub per the established precedent for request-path changes (#30277, #34029)

Before (base commit, same commands): the admin's dashboard GET /v1/mcp/server lists both servers, ?connected_app_view=true marks both connected_app_reachable: false (empty connect picker), and the DCR walk dies at authorize

REACHED CLIENT CALLBACK: http://127.0.0.1:9777/callback?error=access_denied&error_description=the+signed-in+user+has+no+access+to+MCP+server+%27cust_bridge%27...

After (this branch), same admin, fresh session:

$ curl -s "http://127.0.0.1:4612/v1/mcp/server?connected_app_view=true" -H "Authorization: Bearer $UI_SESSION_KEY"
cust_bridge | connected_app_reachable: True
cust_oauth2 | connected_app_reachable: True

$ SERVER=cust_bridge ./repro_walk.sh     # DCR register -> authorize -> token -> MCP initialize -> tools/list
initialize -> HTTP/1.1 200 OK
tools/list -> {"_meta":{"litellm.ai/server_outcomes":{"cust_bridge":{"status":"ok","tool_count":1}}},"tools":[{"name":"cust_bridge-echo",...}]}

$ curl -s http://127.0.0.1:8933/stats    # stub upstream authed_requests went 40 -> 43 with the injected bearer
{"authed_requests": 43, ..., "last_auth_header": "Bearer real_upstream_token_12345"}

Non-admin negative control (fresh internal_user session, same walk) still denies loudly:

location: http://127.0.0.1:9777/callback?error=access_denied&error_description=the+signed-in+user+has+no+access...

Explicit scope still wins: after POST /user/update with object_permission.mcp_servers = [cust_oauth2_id], a fresh admin session resolves only that server. And in-scope servers with no upstream credential surface in _meta instead of vanishing:

outcomes: {"cust_bridge": {"status": "auth_required", "http_status": 401}, "cust_oauth2": {"status": "auth_required", "http_status": 401}}

Type

🐛 Bug Fix

Changes

litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py: new MCPRequestHandler.admin_view_unscoped predicate (the one owner of the admin-view question), and _resolve_admitted_subject_tools treats it as an open channel riding the user's own source

litellm/proxy/_experimental/mcp_server/mcp_server_manager.py: get_allowed_mcp_servers replaces the inline four-clause admin arm and its not is_admitted_subject carve-out with the shared predicate, seeding the registry into the union so the session resource scope bounds every return

Tests: the old pin test_admitted_admin_is_scoped_to_grants_not_full_registry is rewritten to the new invariant (parametrized over PROXY_ADMIN and PROXY_ADMIN_VIEW_ONLY, asserting session equals key view), plus new pins for explicit key scope, unresolved entitlement ceiling, the tools-axis channel with a non-admin negative, the registry seed bounded by a sealed session resource scope, the DB-default empty mcp_servers row still resolving the registry, and a non-admin admitted subject never getting the registry. Each fails on the commit before the code it pins

Second commit (179e581, from review): the credential scope clause in admin_view_unscoped applies only to non-admitted principals, so an admitted admin whose shared permission row exists with the DB-default empty mcp_servers list still resolves the registry, verified live on the rig (connect view flips to reachable True with the [] row in place)

QA runbook

  1. Start a proxy from this branch with a Postgres DATABASE_URL and store_model_in_db: true, master key sk-1234
  2. Register an MCP server with allow_all_keys: false and no grants (any oauth_delegate + dcr_bridge server works; a stub works too)
  3. Log into the UI as the master admin, open the connect flow (/ui/connect?connect_flow=1 or via Claude Code pointing at https://<proxy>/<server>/mcp), and confirm the server now shows as connectable
  4. Complete the OAuth flow from Claude Code and run a tool: tools list and serve without any object permission grant
  5. Create an internal_user, sign in as them, repeat step 4, and confirm the authorize step still redirects with error=access_denied
  6. Grant the admin an explicit object_permission.mcp_servers list and confirm a fresh session resolves only those servers

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

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@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

Strengths:

  • Single predicate, two axes: admin_view_unscoped cleanly owns the admin-view decision for both the server-axis registry resolution and the tools-axis open channel. The previous code had the two axes able to disagree — the new design structurally prevents that.

  • Union seed vs. early return: The registry is now seeded into the union before the session resource scope intersect, rather than returned early, which means mcp_session_resource_server_id still bounds the result for per-server bearer tokens held by admins. This is the right shape.

  • Safety properties are preserved: Explicit object_permission.mcp_servers wins, entitlement ceiling binds (including unresolved), and session resource scope intersects last. The new test test_admin_registry_seed_still_bounded_by_session_resource_scope directly pins this.

  • Tests cover the new invariants well: Parametrized over both admin roles, negative for plain admitted subjects on the tools axis, entitlement ceiling disabling the registry seed, and explicit scope override — these are the right regression pins.

Concerns:

  1. Test inversion without the original kept as a negative control. test_admitted_admin_is_scoped_to_grants_not_full_registry is fully rewritten to assert the opposite. The old invariant — that admitted sessions should be scoped to grants, not the registry — was a deliberate security design choice (the old comment: "a session bearer is a third-party client credential, not the dashboard"). Rewriting it without a companion test making explicit why the old policy was wrong makes it harder for a future reviewer to determine whether a regression toward the old behavior is intentional. A negative control like non_admin_admitted_is_still_scoped_to_grants would help, though the existing test_admitted_admin_tools_ride_own_source_on_ungranted_server partially covers the tools axis.

  2. _user_places_mcp_ceiling is now called for admitted subjects in the hot auth path. Previously, the admitted-subject branch hit MCPRequestHandler.get_allowed_mcp_servers directly; now it calls admin_view_unscoped_user_places_mcp_ceiling_get_allowed_mcp_servers_for_user — a DB query. For non-admins this short-circuits immediately at user_api_key_has_admin_view with no I/O, but for admitted-admin subjects this is a new DB query per request in the auth path. It was already present for admin keys so it's not a new class of problem, but worth confirming the call is cached or bounded.

  3. admin_view_unscoped docstring says "the one owner" but the reachable_via_open_channel computation in _resolve_admitted_subject_tools uses it as an inline or operand. That's correct behavior, but the "both call it" claim in the docstring could confuse a future reader into thinking there are exactly two structured call sites rather than one method call and one inline boolean expression.

None of these block merge; the fix is correctly motivated and the implementation is sound.

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR aligns OAuth-admitted admin sessions with the MCP server set visible to unscoped admin keys while preserving explicit scopes, entitlement ceilings, and per-server session envelopes

  • Centralizes the unscoped admin-view predicate across server and tool authorization
  • Seeds the registry before the final session-resource intersection
  • Adds admin, non-admin, explicit-scope, entitlement, tool-channel, and sealed-session regression coverage

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains; the shared predicate preserves explicit and entitlement scopes, and both normal and fallback server-resolution paths retain the final session-resource intersection

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py Adds the shared admin-view predicate and applies it consistently to admitted-session tool reachability
litellm/proxy/_experimental/mcp_server/mcp_server_manager.py Seeds unscoped admin access into the server union while retaining the final session-resource intersection
tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py Covers admin session parity, explicit and unresolved ceilings, the non-admin negative control, and tool invocation reachability
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py Verifies that a sealed admin session remains restricted to its resource server

Reviews (2): Last reviewed commit: "fix(mcp): bind admitted admin rows throu..." | Re-trigger Greptile

ceiling (including an unresolved one) binds the human whatever their role, so both disqualify.
The one owner of this predicate: the server-axis registry shortcut and the tools-axis admin
channel both call it, so the two axes cannot disagree."""
if user_api_key_auth is None or not user_api_key_has_admin_view(user_api_key_auth):

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.

High: Read-only admins can execute MCP tools

user_api_key_has_admin_view includes PROXY_ADMIN_VIEW_ONLY, but this predicate is also used by _resolve_admitted_subject_tools as an allow-all execution channel. A read-only admin can therefore authorize an aggregate OAuth session and invoke tools on every MCP server without a server grant, despite that role being denied inference and state-changing operations elsewhere. Restrict the admitted-session shortcut to PROXY_ADMIN; if viewer parity is required for discovery, keep that as a separate listing-only predicate that is not used by the tool authorization path.

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.

Probed live: a view-only admin key already passes tools/call and inference authz today, so sessions mirror shipped key semantics. Happy to file a both-shapes follow-up

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.

Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.

@veria-ai

veria-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request changes MCP admin OAuth session resolution so authenticated sessions target the same set of MCP servers displayed on the connect page.

One significant authorization issue remains open: read-only administrators can use aggregate OAuth sessions to execute tools across MCP servers without individual server grants. This creates an execution authorization bypass for a role intended to have view-only access, and no reported issues have yet been addressed.

Open issues (1)

Fixed/addressed: 0 · PR risk: 8/10

@codecov

codecov Bot commented Aug 21, 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

Copy link
Copy Markdown
Contributor Author

@greptileai negative-control test and docstring fix pushed, and the ceiling probe caches per user so warm requests read no rows. Can you reply with a score and why?

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

@codspeed-hq

codspeed-hq Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_mcp_connect_page_parity (179e581) with litellm_internal_staging (9c558df)

Open in CodSpeed

@devin-ai-integration

Copy link
Copy Markdown
Contributor

QA'd this end to end on a live proxy (:4000, Postgres, two bearer-protected stub MCP servers with allow_all_keys false and zero grants), at HEAD 179e581.

As master admin the connect page lists both ungranted servers as connectable, and a full DCR walk (register, authorize, token, initialize, tools/list, tools/call) serves both servers' tools with no object permission grant. The stub upstream saw the gateway-injected stored bearer.

Admin connect page shows both ungranted servers

Negative control and scope checks

Internal user: connect picker is empty, bridge authorize redirects with error=access_denied and no code, and an aggregate session resolves zero tools with tool calls denied.

Internal user bridge authorize denied

179e581 regression: with the admin's ObjectPermission row set to the DB-default mcp_servers=[], a fresh session still resolves the full registry (connect page and a fresh DCR token both show both servers).

Explicit scope: after POST /user/update with mcp_servers=[cust_stub2 id], a fresh session narrows to only cust_stub2 in both the connect page and tools/list.

Explicit scope narrows to cust_stub2

Note on the runbook's step 5 wording: "authorize redirects with error=access_denied" applies to the per-server bridge authorize path (/{server}/authorize); the aggregate /authorize mints a code even for a zero-grant user by design — but that user's session resolves zero tools and tool calls are denied, so there is no registry leak.

Unit tests: 809 passed, 0 failed across the two changed test files.

Tested by Devin: session

@tin-berri
tin-berri merged commit 7cb100a into litellm_internal_staging Aug 22, 2026
73 of 74 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_connect_page_parity branch August 22, 2026 01:16
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