Skip to content

fix(mcp): annotate connected-app reachability on the gateway connect page - #34867

Merged
tin-berri merged 11 commits into
litellm_internal_stagingfrom
litellm_lit4861_connect_scope
Jul 31, 2026
Merged

fix(mcp): annotate connected-app reachability on the gateway connect page#34867
tin-berri merged 11 commits into
litellm_internal_stagingfrom
litellm_lit4861_connect_scope

Conversation

@tin-berri

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Connect page shows servers a gateway session is never served
  • Real report: page shows 7 servers / 253 tools, client gets 171
  • Nothing on the page marks which servers actually count

How it solves it:

  • GET /v1/mcp/server?connected_app_view=true stamps per-server reachability
  • Computed by the exact resolver pair the live session uses
  • Connect page shows only servers the session will be served

Relevant issues

  • The MCP connect page resolves its grid through the dashboard identity, so an admin sees the whole registry, while the gateway DCR session minted by that page resolves servers as an admitted subject through grant sources only
  • This PR adds an opt-in annotation to the server list endpoint plus connect-mode rendering that hides servers the session will not be served, so the page and the session can no longer disagree
  • Existing consumers of GET /v1/mcp/server are unchanged; the field stays null unless the new param is passed

Linear ticket

Resolves LIT-4861

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)

Screenshots / Proof of Fix

Rig: proxy on localhost:4861 backed by real Postgres, four streamable HTTP MCP upstreams serving three tools each, and four servers registered through the API to exercise every grant channel: u1srv granted through the signed-in user's own object permission, t1srv granted through a roster team, a1srv flagged allow_all_keys, x1srv with no grant. The signed-in user is a proxy admin, matching the reported setup. The client leg is the full gateway DCR flow over the wire: /register, /authorize (303 to /ui/chat/integrations?connect_flow=...), POST /authorize/complete with the session cookie, /token with PKCE, then MCP initialize and tools/list with the minted session bearer

Before, at base bdf8f8c:

$ curl -s "http://localhost:4861/v1/mcp/server" -H "Authorization: Bearer $UI_SESSION_KEY"
servers listed: 4        # a1srv, t1srv, u1srv, x1srv; no reachability field exists

$ curl -s "http://localhost:4861/v1/mcp/server?connected_app_view=true" -H "Authorization: Bearer $UI_SESSION_KEY"
servers listed: 4 | any connected_app_reachable field: False        # unknown param silently dropped

$ ./dcr_flow.sh          # register -> authorize -> complete -> token -> MCP tools/list
served tool count: 9
served server prefixes: ['a1srv', 't1srv', 'u1srv']        # x1srv absent, no error anywhere

The page lists 4 connectable servers while the session it just created is served 3. This is the reported 253 vs 171 mismatch reduced to a rig

After, at e244403, same commands against the same database:

$ curl -s "http://localhost:4861/v1/mcp/server?connected_app_view=true" -H "Authorization: Bearer $UI_SESSION_KEY"
  a1srv | connected_app_reachable: True
  t1srv | connected_app_reachable: True
  u1srv | connected_app_reachable: True
  x1srv | connected_app_reachable: False

$ curl -s "http://localhost:4861/v1/mcp/server" -H "Authorization: Bearer $UI_SESSION_KEY"
servers listed: 4 | flags: ['None']        # default unchanged without the param

$ ./dcr_flow.sh
served tool count: 9
served server prefixes: ['a1srv', 't1srv', 'u1srv']        # equals the flagged-true set exactly

UI verification steps: run the dashboard dev server, start a gateway connect flow from any MCP client so the browser lands on /ui/chat/integrations?connect_flow=..., and observe that servers without a grant do not appear at all, while the plain Integrations page without connect_flow renders exactly as before

Type

🐛 Bug Fix

Changes

  • GET /v1/mcp/server gains an opt-in connected_app_view query param; response rows gain connected_app_reachable, default null
  • A new helper builds the admitted-subject auth with MCPRequestHandler._reload_admitted_user and resolves the server set with MCPServerManager.get_allowed_mcp_servers, the same two owners the live /mcp session uses, so the page and the session cannot drift
  • Any failure to build the admitted set marks every server unreachable; a user whose reload fails cannot open a gateway session either, so this is the true answer rather than a fallback
  • The stamp lands after both list branches, so view_all deployments (whose management-endpoint shortcut preempts the server manager's admin shortcut) and team_id queries are annotated uniformly, and it lands before the sanitizers, which preserve it
  • MCPAppsPanel in connect mode requests the flag and hides unreachable servers entirely; the filtered list is the single source for counts, tabs, auto-select, detail view, and tool-count fetches. Client-forwarded auth types keep their existing "Not supported on this connection" label (a server property, unlike reachability, which is caller-relative), and a user with zero reachable servers gets an explanatory empty state

Two things a reviewer may ask. The API annotates rather than filters so the reachability field stays caller-relative and every other consumer of GET /v1/mcp/server is untouched; the connect page hides the annotated-unreachable rows client side, so the page never advertises a server the session cannot use, and the annotation still lands uniformly on the view_all and team_id branches. build_effective_auth_contexts now appends the admitted-user context for UI session credentials, so the page list, tool counts, Connect actions, and credential endpoints all resolve every server the user is granted through any channel identically; the page's membership equals the admitted set by construction and no row can render whose follow-up actions would be denied. On the REST tool routes a non-admin dashboard session is swapped for the admitted-subject principal at the route boundary (acting_user_auth), so tool permission ceilings, reachability, rate limits, and billing bind exactly as they do for a gateway session; admin sessions keep their operator view and caller-passed keys are never widened. The view is honored only for the dashboard's UI session credential, via is_ui_session_credential factored out of resolve_ui_session_team_ids so both user-identity widening sites share one trust boundary; a caller-passed virtual key gets the param as a no-op, so a scoped key can never enumerate its owning user's wider grants The keyless /ui/connect page (which renders the same panel without connect mode) and the enable_chat_ui gating of /ui/chat/integrations are pre-existing gaps tracked on the Linear ticket, kept out of this PR's scope

Three things changed after the last review round, all at the class rather than at the reported call

The connect page's detail view no longer keeps its own copy of the server object. It stores the selected server's id and derives the row from the list, so a server a refetch drops as unreachable cannot be the detail view's subject at all; the stale render, the stale tools query and the Connect action that fell through a missing-target lookup stop being reachable states instead of being blocked one at a time. handleToggle takes the server it is toggling, which deletes the lookup that could miss. Pinned by a test that opens the detail view, refetches a list without that server, and asserts the view falls back to the grid with no connect action anywhere; storing the object again fails it

admitted_user_context is now the single owner of "what user identity does this dashboard session act as". The management endpoint's private copy of the reload, the HTTPException swallow and the logging is gone, along with its dead empty-user_id branch, so the page annotation, build_effective_auth_contexts and the tool routes cannot answer that question differently. The owner also carries the request's tracing span onto the admitted principal, because _reload_admitted_user builds a fresh auth from the user row and has none of its own, so the previous round's swap had silently detached every downstream lookup and the tool-call logging from the request's trace

Toolset scoping and the acting-as-user swap are mutually exclusive, and they now share one owner on the tools list route. The admitted subject resolves per grant source and a team source deliberately carries none of the caller's object_permission, so a toolset narrowing layered on top would evaporate on every team-granted server: a non-admin dashboard user whose own object permission grants a toolset would be admitted through that grant and then served tools from servers the toolset never named. A request carrying a toolset name keeps the caller's own credential, exactly as it did before the swap. The equivalent composition on /toolset/{name}/mcp for a gateway session predates this PR, is untouched by it, and is tracked separately

A fourth followed from reading the rest of the panel's async surface. Three continuations decided against state captured before their await and committed after it, so a reachability refetch landing in between could not be seen. handleToggle validated the server at click time and then wrote its name into the selection whatever the list had since become, so a server the refresh had dropped was selected anyway; it now re-asks connectableNow at the commit, and that predicate resolves the id against the current list, so absence fails closed rather than reading as nothing to block. The load pipeline was the worse of the two because its cancel flag was shared across runs: the successor's effect body reset it to false before the predecessor's fetch resolved, so a superseded load could still run setServers and put the dropped server back on the page outright. That flag is now a per-effect local only its own cleanup can clear, which is also what makes unmount stop the chunked tool-count loop again, and the load passes its own liveness check down to the tool-count and oauth-status writes instead of having them consult a flag every other run shares. Both are pinned by tests that hold the in-flight request open across the refetch, and each mutation kills exactly its own test

QA runbook

Against the same rig, with the dashboard dev server on :3000 and the proxy on :4861

  1. Sign in as the non-admin user and start a gateway connect flow so the browser lands on /ui/chat/integrations?connect_flow=..., then open any server's detail view and leave it open. In another tab revoke that user's grant for that server, then force the panel to reload its list (sign the dashboard session out and back in, which changes the access token the panel keys its fetch on). The detail view should fall back to the grid with the revoked server gone, and no Connect control for it anywhere
  2. curl -s "http://localhost:4861/mcp-rest/tools/list?toolset_name=<a toolset the user's own object permission does not grant>" -H "Authorization: Bearer $UI_SESSION_KEY" should still be refused, the same as before this PR; the toolset request must not be admitted through the user's grant union
  3. curl -s "http://localhost:4861/v1/mcp/server?connected_app_view=true" -H "Authorization: Bearer $UI_SESSION_KEY" should return the same connected_app_reachable flags as before, and the served tool count over the real DCR flow should still equal the flagged-true set exactly

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

Note

Medium Risk
Touches MCP authorization identity resolution (dashboard vs admitted subject) and server listing semantics; behavior is gated to UI session credentials and connect mode, with broad test coverage, but mistakes could affect tool access or visibility for dashboard users.

Overview
Fixes the gateway connect flow showing MCP servers and tool counts that the minted OAuth session never receives.

Backend: GET /v1/mcp/server accepts optional connected_app_view and sets connected_app_reachable on each row using the same admitted-subject resolver as live /mcp sessions (UI session credentials only; virtual keys are not widened). build_effective_auth_contexts now also includes the admitted user context so listings match user-level grants. MCP REST tool list/call routes resolve non-admin dashboard sessions via acting_user_auth (renamed from toolset-only scope), while toolset-scoped requests keep the caller credential so narrowing is not lost on team grants.

Dashboard: In connect mode, MCPAppsPanel requests the new view, filters out unreachable servers, and hardens async behavior (detail view by server id, per-load cancellation, re-check after Connect) so refetches cannot leave stale UI or selections.

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

…page

The MCP connect page resolved its server grid through the dashboard identity
(admin shortcut or view_all returns the whole registry) while the gateway DCR
session it sets up resolves servers as an admitted subject through grant
sources only, so the page showed servers and tool counts the session is never
served. GET /v1/mcp/server now accepts connected_app_view=true and stamps each
returned server with connected_app_reachable, computed by the same
_reload_admitted_user + get_allowed_mcp_servers pair the live session uses.
The connect page requests the flag in connect mode and renders unreachable
servers dimmed with a label, excluded from the Connected count and tool-count
fetches. Failure to build the admitted set marks everything unreachable, which
matches what such a session would actually be served. Default behavior without
the param is unchanged for every existing consumer.
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR aligns the MCP gateway connect page with the servers available to the connected-app session.

  • Adds an opt-in per-server reachability annotation to the MCP server-list endpoint.
  • Resolves dashboard UI sessions through the admitted-user identity used by gateway sessions and MCP REST routes.
  • Filters unreachable servers in connect mode and derives detail selection from the latest reachable list.
  • Adds per-load cancellation and commit-time checks to prevent stale asynchronous work from restoring or selecting removed servers.
  • Extends backend and frontend tests for reachability, authorization context, and refetch races.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported stale-detail and in-flight-selection failures are addressed by deriving details from the current server list, synchronously updating the current-list reference, rechecking reachability after asynchronous tool loading, and isolating cancellation state per load; no blocking failure remains.

Important Files Changed

Filename Overview
litellm/models/mcp_server.py Adds the nullable connected-app reachability response field while preserving the default API shape.
litellm/proxy/_experimental/mcp_server/ui_session_utils.py Centralizes dashboard-session detection, admitted-user resolution, acting-user selection, and effective auth contexts.
litellm/proxy/_experimental/mcp_server/rest_endpoints.py Applies the admitted-user principal to MCP REST list and call routes while preserving caller credentials for toolset-scoped requests.
litellm/proxy/management_endpoints/mcp_management_endpoints.py Adds the opt-in reachability annotation using the same admitted-subject server resolver as gateway sessions.
ui/litellm-dashboard/src/components/networking.tsx Extends the existing MCP server-list wrapper to request connected-app annotations.
ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx Filters unreachable connect-mode servers and prevents stale detail, selection, and list-load state after reachability changes.
ui/litellm-dashboard/src/components/chat/MCPAppsPanel.test.tsx Covers connect-mode filtering, unsupported servers, detail invalidation, in-flight selection, and superseded list loads.

Reviews (13): Last reviewed commit: "fix(mcp): write the connect-page server ..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Patch % Lines
...m/proxy/_experimental/mcp_server/rest_endpoints.py 40.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

…detail view

A server the connect page marks unavailable could still be added through its
detail view Connect action, so the selection could contain servers the
connected-app session is never served. The unavailability decision now lives in
one predicate, connectUnavailabilityLabel, consumed by the card indicator, the
detail view action area, the toggle-on path, the oauth auto-select effect, and
the Connected count, so no interaction path can disagree with the label. This
also closes the same pre-existing hole for servers marked not supported on this
connection, whose detail view likewise offered Connect, and removes a
grandfathered nested ternary, ratcheting the eslint suppressions baseline down
@tin-berri

Copy link
Copy Markdown
Contributor Author

Addressed the P1 in b644140 at the mechanism level. The unavailability decision now lives in one predicate, connectUnavailabilityLabel, and every surface that could add or count a server consumes it: the card indicator, the detail view action area (which renders the label instead of any Connect control), the toggle-on path in handleToggle, the oauth auto-select effect, and the Connected count, so no interaction path can disagree with the label. This also closed the same pre-existing hole for servers labeled not supported on this connection, whose detail view likewise offered Connect. Regression tests drive the detail view for both unavailable states in connect mode and assert the Connect action is absent, plus pin that it still renders outside connect mode; the detail-view gate is mutation-checked. The eslint suppressions baseline ratchets down by one grandfathered nested ternary removed in the restructure.

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The fix is solid. The centralization into connectUnavailabilityLabel is the right approach — all five interaction paths (card indicator, detail action area, handleToggle, oauth auto-select effect, Connected count) now consume the single predicate, so they can't drift from each other.

A few observations on the implementation:

  1. connectUnavailabilityLabel memoization is correct — it only depends on connectMode, so it won't cause spurious re-renders.

  2. The detail view gate is cleanrenderDetailAction checks connectUnavailabilityLabel(detailServer) first and returns the label text, preempting both the non-OAuth Connect button and the OAuth OAuth2ConnectButton, so neither path is reachable for an unavailable server.

  3. handleToggle guard is correct — the early return on connectUnavailabilityLabel(target) !== null also covers the case where target is undefined (falsy guard if (target && ...)), which is the right behavior since there's no server to block.

  4. Test coverage is thorough — the three new tests drive the detail view for both unavailability states, assert the Connect button is absent, pin that it renders outside connect mode (mutation check), and verify tool-count fetch exclusion for unreachable servers.

One minor note: in the "blocks connecting" test, unsupported_srv has auth_type: "oauth2_token_exchange" with connected_app_reachable: true. The test correctly expects the transport label ("Not supported on this connection") to appear and no Connect button — this validates the isUnsupportedOnGatewayConnect branch in the predicate, not just the reachability branch. That's the right thing to verify.

The P1 is fully addressed. The fix is correct.

@codspeed-hq

codspeed-hq Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit4861_connect_scope (a6d3b17) with litellm_internal_staging (81ff7cb)1

Open in CodSpeed

Footnotes

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

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

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai rereview and score properly

@devin-ai-integration

devin-ai-integration Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Update: re-QA'd at 5ace001672 (the union commit) and found a dead end for exactly the callers that commit targets. Same rig as before, live proxy behind a public HTTPS tunnel, Claude-Code-style MCP client doing real DCR, /authorize, loopback callback, then MCP initialize + tools/list

The interesting caller is a non-admin internal user whose management listing is a strict subset of what their gateway session can reach; acme_srv via their own object permission, initech_srv via a team, globex_srv via allow_all_keys, vault_srv granted by nothing

The union branch fires, but the server it adds back cannot be connected

Their plain integrations page lists two servers; acme_srv is missing even though the session can reach it:

plain page

In connect mode acme_srv is unioned back in, exactly as intended, and vault_srv stays hidden:

connect page

Clicking Connect on it dead-ends:

403

{"detail":{"error":"Access denied to MCP server 2f22deaf-905a-4eff-af10-3b7236d7ee24"}}

/v1/mcp/server/oauth/{id}/authorize still resolves through the narrower management path, so it refuses precisely the servers the connect view now adds. No upstream credential gets stored, and after "Finish connecting" the client is served 7 tools from 2 servers while the page listed 3:

7 served tools

The three sets for that caller:

GET /v1/mcp/server                         -> {globex_srv}
GET /v1/mcp/server?connected_app_view=true -> {acme_srv, globex_srv, initech_srv}  (all true)
real tools/list over their gateway session -> {globex_srv(4), initech_srv(3)} = 7 tools

Before the union commit the same user never saw acme_srv at all, so this trades a silent omission for a visible dead end; either the OAuth-start endpoint should accept the same admitted set, or an unioned-in server needs an honest non-connectable state

Admin fixture is still exact at this HEAD: 3 servers, 12 tools

Connect page lists exactly acme/globex/initech with vault_srv hidden, reaches "Connected (3)" with counts 5 / 4 / 3, and the client receives exactly those 12 tools

Connected (3)

12 tools

Earlier passes at ffc22f2 (hide instead of dim)

vault_srv gone from the connect page entirely, no dimmed row and nothing to click:

connect page hides vault_srv

Revoking every grant reaches the new empty state, and it is honest; finishing that same flow the client is served zero tools:

empty state

zero tools

Regression: plain integrations page and the no-flag payload are unchanged

regression

no flag: acme=None globex=None initech=None vault=None
flag:    acme=True globex=True initech=True vault=False

Two things noticed that predate this PR: on a cold proxy with no stored upstream credentials the connect-page tool counts render blank and authorizing a server over OAuth does not refetch its count, so "· N" only appears after a reload; and a user who claims an invitation link gets a UI session token without an exp claim while /authorize requires one, so a freshly signed-up user is bounced to SSO until they log in again through the form

Not exercised live: the sibling "Not supported on this connection" label (the fixture has only authorization_code servers) and the admitted-subject-reload-failure fallback. I have not yet re-run against dcfc10d247

…ming them

Product decision: the connect page should only show what a connected-app
session will actually be served, so annotated-unreachable servers are now
filtered out of the connect-mode list at fetch time rather than rendered
dimmed. Unsupported auth types keep their existing dimmed label since they are
a property of the server, not the caller. A user with zero reachable servers
gets an explanatory empty state pointing at grants. The list filter is the
single source: counts, tabs, auto-select, detail view, and tool-count fetches
all derive from the already-filtered state
@tin-berri

Copy link
Copy Markdown
Contributor Author

Product decision after review: in connect mode the page now hides annotated-unreachable servers entirely instead of dimming them (ffc22f2), so the connect surface lists exactly what the session will be served. The API contract is unchanged (opt-in connected_app_view param, connected_app_reachable field); the filter is applied once where the list is set, so counts, tabs, auto-select, the detail view, and tool-count fetches all derive from the filtered state. Unsupported auth types keep their dimmed label since that is a property of the server rather than of the caller, and a user with zero reachable servers gets an explanatory empty state. Tests updated: hiding is asserted in connect mode, full rendering asserted outside it, and the filter is mutation-checked

@greptileai

Comment thread ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx Outdated
…rver

The connect view's membership came from the dashboard resolver with the
admitted-subject answer only annotated on top, so a server reachable by the
session but missing from the dashboard list would be invisible on the page; an
under-report, the mirror of the bug this PR fixes. The connect view now unions
in any session-reachable server the dashboard resolver did not list, built
from the registry and redacted through the same ladder, so page membership
equals the admitted set by construction in both directions
@tin-berri

Copy link
Copy Markdown
Contributor Author

One more commit, 5ace001: the connect view now guarantees the invariant in both directions. Previously membership came from the dashboard resolver with reachability only annotated on top, so a server the session is served but the dashboard resolver missed would be invisible on the page (an under-report, the mirror image of the reported bug). The connect view now unions in session-reachable servers missing from the dashboard list, built from the registry and passed through the same redaction and sanitizer ladder, so page membership equals the admitted set by construction. Pinned by a test running as an internal user through the non-admin sanitizer, with the union arm mutation-checked

@greptileai

Comment thread litellm/proxy/management_endpoints/mcp_management_endpoints.py Outdated
@veria-ai

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

…credential

The reachability view resolves through the owning user's admitted identity, so
a caller-passed virtual key could use the param to enumerate servers beyond
its own scope (ids, names, descriptions of the owner's wider grants). The view
is now gated on is_ui_session_credential, a predicate factored out of
resolve_ui_session_team_ids so the two user-identity widening sites share one
trust boundary: the SSO-minted dashboard session token acting as its user. Any
other credential gets the param as a no-op and the admitted resolver is never
consulted for it
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/management_endpoints/mcp_management_endpoints.py Outdated
…re, not per endpoint

The list endpoint unioned in session-reachable servers itself while tool
counts, Connect actions, and credential endpoints still authorized through
build_effective_auth_contexts, whose contexts carry team grants but never the
user row's own object permission; a user-granted server could render on the
connect page while every interaction on it failed. The admitted-user context
(the same auth a gateway session resolves with) is now appended inside
build_effective_auth_contexts for UI session credentials, so the page list and
every per-server action endpoint answer identically, and the list endpoint's
one-off union is deleted. Caller-passed keys are still never widened
(is_ui_session_credential gate inside the context builder) and a reload
failure falls back to team contexts only
@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai rereview

Comment thread litellm/proxy/_experimental/mcp_server/ui_session_utils.py

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

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re-ran the same QA at dea4092b68, and the dead end I reported earlier is fixed; treat that earlier comment as superseded.

Same fixture and same caller as before: a non-admin internal user whose management listing used to be a strict subset of what their gateway session could reach (acme_srv via the user row's own object permission, globex_srv via allow_all_keys, initech_srv via a team, vault_srv granted by nothing).

The previously-403ing server is now genuinely connectable, and the page matches the client exactly

Connect on acme_srv used to land on raw JSON from /v1/mcp/server/oauth/{id}/authorize; it now reaches the real IdP consent:

acme consent

The credential actually lands, which matters because the panel marks a card connected optimistically:

$ psql -c 'select user_id, server_id from "LiteLLM_MCPUserCredentials";'
    user_id    |              server_id
---------------+--------------------------------------
 qa_union_user | 2f22deaf-905a-4eff-af10-3b7236d7ee24

All three authorize, ending at "Connected (3)" with counts 5 / 4 / 3 and vault_srv still hidden:

connected 3

Then "Finish connecting", and the client's real tools/list:

12 served tools

plain /ui/chat/integrations       -> {acme_srv, globex_srv, initech_srv}
connect page                      -> {acme_srv, globex_srv, initech_srv}
real tools/list (gateway session) -> {acme_srv(5), globex_srv(4), initech_srv(3)} = 12 tools

Exact equality in both directions for the caller the earlier commits targeted; at the previous HEAD this same user saw 3 servers listed and was served 7 tools from 2.

One behavior change worth confirming is deliberate: because the admitted context is no longer connect-view-only, that user's plain integrations page widened too, from two servers to three, and now reads "12 tools available".

plain page widened

Gating holds, admin baseline unchanged, vault_srv still contained

A virtual key for the same user still gets the unannotated payload with no widening, so the shared context did not leak to caller-passed keys:

gating

Admin still reaches "Connected (3)" with 5 / 4 / 3 and is served exactly those 12 tools, and the plain page still lists all four with vault_srv connectable:

admin connected 3

admin plain page

vault_srv, granted by nothing, stayed absent from connect mode for both callers and no vault_srv tool reached either client.

Still not exercised live: the sibling "Not supported on this connection" label (the fixture has only authorization_code servers) and the reload-failure fallback to team contexts only.

…t on tool routes

Server reachability on the REST tool routes came from the widened context
union while tool permission checks ran on the bare session key, which carries
no object permission, so a dashboard user could invoke tools their user-level
grant excludes. Rather than bookkeeping which context granted which server,
the routes now choose one principal at the boundary: acting_user_auth swaps a
non-admin UI session for the admitted-subject auth, the same identity a
gateway session resolves with, so reachability, per-source fail-closed tool
ceilings, rate limits, and billing attribution all bind through the admitted
arms that already exist downstream. Admin sessions keep their operator view
and caller-passed credentials are never widened. One swap point per route,
no per-server principal picking, no parallel permission logic
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re-ran the QA at 84f525bc71, focused on the two tool routes. Everything holds, and nothing regressed against the previous head.

Same fixture and caller as before: a non-admin internal user with acme_srv granted only by their own object permission, globex_srv via allow_all_keys, initech_srv via a team, vault_srv granted by nothing.

The tool list route and a real in-dashboard tool call both resolve as the admitted subject

Connect page counts for that user, and the same user's real tools/list over a gateway session minted by a Claude-Code-style client:

connect page

12 served tools

connect page                      -> {acme_srv 5, globex_srv 4, initech_srv 3}
real tools/list (gateway session) -> {acme_srv 5, globex_srv 4, initech_srv 3} = 12 tools

And an actual invocation from the Tool Testing Playground of a tool only that user's own object permission grants; the bare session key carries no object permission, so this is the call that matters:

acme call

Tool executed successfully - 0.07s
{"provider": "acme", "tool": "list_queues", "payload": "v6-nonadmin"}
The interesting check: a per-user tool ceiling now actually binds on both routes

Worth noting that a plain happy-path run does not distinguish this fix from the bug, because the old behavior was over-permissive: a principal with no object permission gets None back from get_allowed_tools_for_server, i.e. no ceiling at all. So I set one mid-session on the user's own object permission, {"<acme id>": ["list_queues"]}, and re-tested.

The excluded sibling is refused on the call route:

refused

Tool 'close_ticket' is not allowed for your key/team on server 'acme_srv'. Contact proxy admin for access.

and the list route narrows from five tools to one:

narrowed

The allowed tool still executes and the card counts follow the ceiling (acme 1, "8 tools available"), so it restricts rather than breaks the route. Before this change both of these would have shown all five tools and let close_ticket through.

Admin operator view and virtual keys unchanged

Admin still sees all four servers with vault_srv connectable, and can list and call its tools, so the admin session was not narrowed:

admin plain page

admin vault call

Admin connect mode is still exactly three servers with 5 / 4 / 3 and 12 served tools, and a caller-passed virtual key still gets the identical unannotated, unwidened payload with and without connected_app_view=true.

virtual key

Two things I could not exercise. A vault_srv tool call refused for the non-admin has no UI path at all, since vault_srv is absent from that user's whole dashboard; I asserted structural absence and zero vault_srv-* tools served instead. And the reload-failure fallback in acting_user_auth was not induced live.

Minor UI observation, not a blocker: connect-page counts now refetch on connect but render one step behind, so the server you just authorized shows a blank count until the next re-render; one reload settles all of them.

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you review this, give me a score and explain why

…rver list

The detail view held its own copy of the server object, so it outlived the list it came
from. When a refetch dropped that server as unreachable, the open detail view kept
rendering it and its Connect action still ran: the guard looked the server back up by id
or name in the current list, found nothing, and fell through, because a missing target
read as "nothing to block" rather than "no longer connectable"

Store the selected server's id and derive the row from the list instead. A server the
list no longer carries cannot be the detail view's subject, so the stale render, the
stale tools query and the guard bypass stop being reachable states rather than being
blocked one at a time. handleToggle now takes the server it is toggling, which deletes
the lookup that could miss at all
Three call sites reloaded the admitted subject independently, and the management
endpoint carried its own copy of the reload, the HTTPException swallow and the logging.
admitted_user_context is now the only place that answers "what user identity does this
dashboard session act as", and the connected-app reachability helper reads it, which
also drops its dead empty-user_id branch

That owner now carries the request's tracing span onto the admitted principal.
_reload_admitted_user builds a fresh auth from the user row and has no span of its own,
so swapping it in on the REST tool routes silently detached every downstream lookup and
the tool-call logging from the request's trace

Toolset scoping and the acting-as-user swap are mutually exclusive, so they now share
one owner on the tools list route. The admitted subject resolves per grant source and a
team source deliberately carries none of the caller's object_permission, so a toolset
narrowing layered on top would evaporate on every team-granted server: the request would
be admitted through the toolset grant and then served tools from servers the toolset
never named. A request carrying a toolset name stays on the caller's own credential,
exactly as it did before the swap
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you review, give me a score and explain 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 f5ecf60. Configure here.

…t stands

Three continuations in the panel decided against state captured before their await and
committed after it, so a reachability refetch landing in between could not be seen

handleToggle validated the server at click time and then, once listMCPTools resolved,
wrote its name into the selection whatever the list had since become; a server the
refresh had dropped was selected anyway. It now re-asks connectableNow at the commit,
and that predicate resolves the id against the current list, so absence fails closed
instead of reading as nothing to block

The load pipeline was worse, because its cancel flag was shared across runs: the
successor's effect body reset it to false before the predecessor's fetch resolved, so a
superseded load could still run setServers and put the dropped server back on the page
outright. The flag is now a per-effect local that only that run's cleanup can clear,
which is also what makes unmount stop the chunked tool-count loop again. The load
passes its own liveness check down to the tool-count and oauth-status writes rather
than having them consult a flag they share with every other run
@tin-berri

Copy link
Copy Markdown
Contributor Author

Real, and fixed in c12085a, along with a second instance of it that was reachable by the same refetch

You are right that handleToggle decided at click time and committed after the await. It now re-asks at the commit, through one predicate: connectableNow(serverId) resolves the id against the current list and returns the row only if it is still connectable, so absence fails closed instead of reading as "nothing to block". The same predicate guards entry, so the two edges of the await cannot drift

Reading the rest of the panel's async surface for the same shape turned up the worse one. The load pipeline's cancel flag was shared across runs, and the successor's effect body reset it to false before the predecessor's fetch resolved, so a superseded load could still reach setServers and put the dropped server back on the page outright, not just into the selection. That flag is now a per-effect local that only its own cleanup can clear, which also restores unmount cancellation for the chunked tool-count loop, and the load hands its own liveness check down to the tool-count and oauth-status writes rather than having them consult a flag every other run shares

Both are pinned by tests that hold the in-flight request open across the refetch and then release it: one asserts onChange is never called for the dropped server, the other asserts a superseded load cannot restore it. Each mutation kills exactly its own test and no other. Panel suite is 10 green, the chat suites 45, dashboard lint clean with component complexity one below where this PR started

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re-ran the QA at c12085aea3. The standing baseline all holds and nothing regressed, but I could not prove either of the two guards this commit adds, so I want to be upfront that the headline behavior is unverified rather than confirmed.

Why the two new guards are unproven (and what would make them testable)

The stale-detail case needs a refetch that drops a server while the panel stays mounted. The list comes from one effect keyed on [accessToken, connectMode, ...], and the only in-app trigger is the sidebar Integrations button, which is a no-op when the pathname and query are unchanged. A full reload does remove the revoked server, but it remounts the panel and resets detailServerId, so it only exercises fresh-load filtering, not the detailServer = servers.find(...) fallback.

For the post-await re-check I armed a 120s delay at the HTTP edge on the tools list for an auth_type=none server, clicked Connect, then revoked the grant mid-flight. The server was correctly not added and no credential row appeared, but the delayed request came back 403 once the grant was gone, so handleToggle returned on the result?.error branch before reaching the post-await connectableNow check. That outcome is identical on a build without the guard, so it is not evidence. Making it provable needs a delayed response that still succeeds while the server has left the list; I can extend the QA mock to synthesize one if you want it proven on camera.

Both are reported as untested on purpose; neither is a defect I observed.

Baseline at this HEAD: page and client agree exactly, and the ceiling still binds

Non-admin strict-subset user, connect page against that user's real gateway tools/list:

connect page                      -> {acme_srv 5, globex_srv 4, initech_srv 3}
real tools/list (gateway session) -> {acme_srv 5, globex_srv 4, initech_srv 3} = 12 tools
vault_srv (granted by nothing)    -> absent from the page, 0 tools served

non-admin connect page

The rewired tool routes still enforce a per-user ceiling; with {"<acme>": ["list_queues"]} the list narrows 5 -> 1, the allowed tool runs, and the excluded sibling is refused:

close_ticket refused

Toolset scope (API-level, since listMCPTools never sends toolset_name and the route rejects the session cookie): unscoped 4, qa_subset_ts 3, qa_subset2_ts 1. Worth knowing that permission and toolset changes are cached for roughly a minute, so the first reads after a grant are stale.

Admin baseline and virtual keys unchanged

Admin connect mode is exactly three servers with 5 / 4 / 3 and vault_srv hidden, and the admin client was served exactly those 12 tools:

admin connect page

The admin plain page still lists everything with vault_srv connectable, and its tool list and a call both work:

admin vault call

A caller-passed virtual key still gets the identical unannotated payload with and without connected_app_view=true:

virtual key

Also unexercised: parent_otel_span propagation, a non-admin vault_srv tool call (no UI path, since that server is absent from the user's whole dashboard), and the "Not supported on this connection" label.

…itted

connectableNow resolves a server id against serversRef, but that ref was a mirror kept
in step by a passive effect, so it lagged the state it mirrored by however long React
took to render and flush. A continuation resolving inside that window read the previous
list: the commit-time reachability check would find a server the refetch had already
dropped, call it connectable, and select it, which is the mismatch the check exists to
prevent

The lag was the whole defect, so the mirror is gone. commitServers writes the ref and
the state together, at the one point the list is ever replaced, and the ref is now
never older than the last committed list. Readers that want the newest answer
(connectableNow, the oauth auto-select effect) get it; rendering still derives from
state, so what is on screen is unchanged

Pinned by a test that resolves the refetch and the in-flight Connect in the same tick,
with no render flushed between them, which is the interleaving the earlier regression
could not reach. The two prop mirrors are deliberately untouched: their staleness is
inherent to appending to a parent-owned list from an async callback rather than caused
by the mirror, and no reachability decision reads them
@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@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 a6d3b17. 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 enabled auto-merge (squash) July 31, 2026 05:14
@tin-berri
tin-berri merged commit 4d2b722 into litellm_internal_staging Jul 31, 2026
82 of 83 checks passed
@tin-berri
tin-berri deleted the litellm_lit4861_connect_scope branch July 31, 2026 05:38
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re-ran the QA at a6d3b1763c (posting after the merge for the record). The check I could not prove last round is now proven: a server dropped from the list while its Connect is in flight is not selected, even when the tools request comes back a genuine 200.

The post-await guard, with the response actually succeeding this time

The previous attempt was worthless because the revocation that creates the race also turned the delayed response into a 403, so handleToggle returned on the error branch before reaching the re-check. This time the QA mock prefetches the tools list with an operator credential, caches that real 200 body, and only then holds the response, so releasing it after revocation resolves the await with a success payload.

Precondition, plain_srv granted and its detail view showing its two tools:

precondition

Connect clicked, response held, grant revoked, list re-committed while the request is still in flight. The server is gone from the grid and the open detail view has fallen back to the grid with no Connect control:

re-committed mid-flight

Releasing the hold gives "GET /mcp-rest/tools/list?server_id=9c11aa77-...-plainsrv0001" 200 975 at the edge, and the composer still shows three selected servers with no plain_srv, and no credential row was written:

not added

Negative control, so this is not vacuous: the identical sequence without the revocation does add it, badge 3 -> 4.

negative control

Two honest caveats. The panel still has no user-facing in-place refetch (the sidebar Integrations push is deduplicated against the current route), so the re-commit was triggered by a native history.pushState query change from the page context; that is what a router.push with a different query does, and everything after it is real component code on the same mounted instance, which a no-remount control proves. And this distinguishes the build from one with no guard at all; it does not isolate this commit from c12085aea3, since the passive mirror that was removed lagged by only a render flush while the held response resolves seconds later.

Baseline unchanged: the page and a real MCP client still agree exactly
connect page                      -> {acme_srv 5, globex_srv 4, initech_srv 3}
real tools/list (gateway session) -> {acme_srv 5, globex_srv 4, initech_srv 3} = 12 tools
vault_srv (granted by nothing)    -> absent from the page, 0 tools served

gateway tools/list

Cold load settles to 5 / 4 / 3, detail open/connect/disconnect/back behaves, the oauth auto-select still picks up connected servers, and switching in and out of connect mode never renders a count against a server the newer list does not contain. One cosmetic leftover, not new here: the header's aggregate "N tools available" is summed from a map that keeps entries for servers that have left the list, so it can briefly overcount while the grid and the per-card counts are correct.

Admin view and virtual keys

Admin still sees every server with vault_srv connectable, and admin connect mode is still exactly acme/globex/initech:

admin plain page

admin connect mode

A caller-passed virtual key gets the identical unwidened payload with and without connected_app_view=true ({globex_srv}, connected_app_reachable = null). Toolset and mcp_tool_permissions ceiling checks were not re-run since no backend code changed in this commit.

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