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

* fix(mcp): annotate connected-app reachability on the gateway connect 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.

* fix(mcp): block connecting unavailable servers from the connect-mode 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

* fix(mcp): hide unreachable servers on the connect page instead of dimming 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

* fix(mcp): guarantee the connect view lists every session-reachable server

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

* fix(mcp): honor connected_app_view only for the dashboard UI session 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

* fix(mcp): resolve UI sessions with the admitted-user context everywhere, 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

* fix(mcp): resolve non-admin dashboard sessions as the admitted subject 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

* fix(mcp): derive the connect page's detail view from the reachable server 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

* refactor(mcp): one owner for the identity a dashboard session acts as

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

* fix(mcp): commit every async connect-page write against the list as it 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

* fix(mcp): write the connect-page server list to its ref as it is committed

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
This commit is contained in:
tin-berri 2026-07-30 22:38:54 -07:00 committed by GitHub
parent d0fe305810
commit 4d2b7224fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1015 additions and 112 deletions

View file

@ -102,6 +102,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
byok_description: List[str] = Field(default_factory=list)
byok_api_key_help_url: Optional[str] = None
has_user_credential: Optional[bool] = None
connected_app_reachable: bool | None = None
source_url: Optional[str] = None
timeout: Optional[float] = None
max_concurrent_requests: Optional[int] = None

View file

@ -26,6 +26,7 @@ from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
list_fault_http_status,
)
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
acting_user_auth,
build_effective_auth_contexts,
)
from litellm.proxy._experimental.mcp_server.utils import (
@ -667,13 +668,19 @@ if MCP_AVAILABLE:
"""Coerce an Optional[str] Query param to str|None, dropping unresolved FastAPI defaults."""
return value if isinstance(value, str) else None
async def _resolve_toolset_scope(
async def _resolve_acting_auth(
toolset_name: str | None,
user_api_key_dict: UserAPIKeyAuth,
) -> UserAPIKeyAuth:
"""Resolve ``toolset_name`` to its scoped ``UserAPIKeyAuth``, or return unchanged."""
"""The one credential this tools request acts as.
A toolset name narrows the caller's own credential to that toolset; otherwise a dashboard
session is swapped for its admitted subject. The two are mutually exclusive by construction,
which is why they share an owner: 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."""
if not toolset_name:
return user_api_key_dict
return await acting_user_auth(user_api_key_dict)
from litellm.proxy.utils import get_prisma_client_or_throw
@ -731,6 +738,7 @@ if MCP_AVAILABLE:
try:
mcp_server_name = _as_query_str(mcp_server_name)
toolset_name = _as_query_str(toolset_name)
user_api_key_dict = await _resolve_acting_auth(toolset_name, user_api_key_dict)
# The full catalog (allowlist filter skipped) is admin-only so the
# REST endpoint can't be used to enumerate deliberately-disabled tools.
@ -738,8 +746,6 @@ if MCP_AVAILABLE:
include_disabled_tools and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
)
user_api_key_dict = await _resolve_toolset_scope(toolset_name, user_api_key_dict)
if server_id is None:
server_id = mcp_server_name
@ -928,6 +934,7 @@ if MCP_AVAILABLE:
)
try:
user_api_key_dict = await acting_user_auth(user_api_key_dict)
data = await request.json()
tool_name = data.get("name")

View file

@ -1,9 +1,11 @@
"""Helpers to resolve real team contexts for UI session tokens."""
"""Helpers to resolve the identity a dashboard UI session token acts as."""
from __future__ import annotations
from typing import List
from fastapi import HTTPException
from litellm._logging import verbose_logger
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.proxy._types import UserAPIKeyAuth
@ -23,12 +25,19 @@ def clone_user_api_key_auth_with_team(
return cloned_auth
def is_ui_session_credential(user_api_key_auth: UserAPIKeyAuth) -> bool:
"""Whether the caller is the dashboard's SSO-minted session token acting as its user,
the only credential shape allowed to widen a request to the owning user's identity."""
return user_api_key_auth.team_id == UI_SESSION_TOKEN_TEAM_ID and bool(user_api_key_auth.user_id)
async def resolve_ui_session_team_ids(
user_api_key_auth: UserAPIKeyAuth,
) -> List[str]:
"""Resolve the real team ids backing a UI session token."""
if user_api_key_auth.team_id != UI_SESSION_TOKEN_TEAM_ID or not user_api_key_auth.user_id:
if not is_ui_session_credential(user_api_key_auth):
return []
from litellm.proxy.auth.auth_checks import get_user_object
@ -68,12 +77,63 @@ async def resolve_ui_session_team_ids(
return resolved_team_ids
async def admitted_user_context(user_api_key_auth: UserAPIKeyAuth) -> UserAPIKeyAuth | None:
"""THE owner of "resolve this dashboard session's user identity": the same admitted-subject auth a
gateway OAuth session for this user resolves with, carrying the user row's own object permission,
on this request's tracing span. None for any other credential (a caller-passed key is never
widened) and on reload failure, which every caller reads as "no user-level identity available"."""
user_id = user_api_key_auth.user_id
if not is_ui_session_credential(user_api_key_auth) or user_id is None:
return None
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)
try:
admitted = await MCPRequestHandler._reload_admitted_user(user_id)
except HTTPException as e:
verbose_logger.warning(f"MCP dashboard session: admitted-subject reload failed for {user_id}: {e.detail}")
return None
return admitted.model_copy(update={"parent_otel_span": user_api_key_auth.parent_otel_span})
async def acting_user_auth(user_api_key_auth: UserAPIKeyAuth) -> UserAPIKeyAuth:
"""The principal acting-as-user MCP routes resolve permissions with. A non-admin dashboard
session acts as the admitted subject, the same identity a gateway session resolves with, so
server reachability, per-source tool ceilings, rate limits, and billing bind identically on
both surfaces. An admin session keeps its operator view and any caller-passed credential is
returned unchanged, never widened.
Do not combine this with a narrowing that rewrites a single credential's ``object_permission``
(toolset scope): the admitted subject resolves per grant source and a team source deliberately
carries none of the caller's own grants, so the narrowing would silently evaporate on every
team-granted server. A request carrying such a scope keeps the caller's own credential."""
if not is_ui_session_credential(user_api_key_auth):
return user_api_key_auth
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
if _user_has_admin_view(user_api_key_auth):
return user_api_key_auth
admitted = await admitted_user_context(user_api_key_auth)
return admitted if admitted is not None else user_api_key_auth
async def build_effective_auth_contexts(
user_api_key_auth: UserAPIKeyAuth,
) -> List[UserAPIKeyAuth]:
"""Return auth contexts that reflect the actual teams for UI session tokens."""
"""Every auth context a management or listing surface must resolve a UI session token through:
one per real team backing the session, plus the session user's own admitted identity, so a grant
made directly to the user row is as visible to the dashboard as it is to a gateway session."""
resolved_team_ids = await resolve_ui_session_team_ids(user_api_key_auth)
if resolved_team_ids:
return [clone_user_api_key_auth_with_team(user_api_key_auth, team_id) for team_id in resolved_team_ids]
return [user_api_key_auth]
team_contexts = (
[clone_user_api_key_auth_with_team(user_api_key_auth, team_id) for team_id in resolved_team_ids]
if resolved_team_ids
else [user_api_key_auth]
)
admitted_context = await admitted_user_context(user_api_key_auth)
if admitted_context is None:
return team_contexts
return [*team_contexts, admitted_context]

View file

@ -148,7 +148,9 @@ if MCP_AVAILABLE:
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
admitted_user_context,
build_effective_auth_contexts,
is_ui_session_credential,
)
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
@ -939,6 +941,16 @@ if MCP_AVAILABLE:
aggregated.setdefault(server.server_id, server)
return list(aggregated.values())
async def _connected_app_reachable_server_ids(user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]:
"""Server ids a connected app authorized by this dashboard user is served on the aggregate
MCP endpoint, resolved through the one owner of the admitted subject so the page and the
session cannot drift. Empty when that identity cannot be built, which is the true answer:
the same user cannot open a gateway session either."""
admitted = await admitted_user_context(user_api_key_dict)
if admitted is None:
return frozenset()
return frozenset(await global_mcp_server_manager.get_allowed_mcp_servers(admitted))
@router.get(
"/server",
description="Returns the mcp server list with associated teams",
@ -953,6 +965,12 @@ if MCP_AVAILABLE:
"servers the team has access to plus globally available (allow_all_keys) servers. "
"Used by the Create Key UI to show team-scoped MCP servers.",
),
connected_app_view: bool = Query(
False,
description="Annotate each returned server with connected_app_reachable: whether a "
"connected app authorized by the calling user (a gateway OAuth session) is served "
"this server on the aggregate MCP endpoint.",
),
):
"""
Get all of the configured mcp servers for the user in the db with their associated teams
@ -1009,6 +1027,11 @@ if MCP_AVAILABLE:
servers = await _resolve_accessible_mcp_servers(user_api_key_dict)
redacted_mcp_servers = _redact_mcp_credentials_list(servers)
if connected_app_view is True and is_ui_session_credential(user_api_key_dict):
reachable_ids = await _connected_app_reachable_server_ids(user_api_key_dict)
for server in redacted_mcp_servers:
server.connected_app_reachable = server.server_id in reachable_ids
# augment the mcp servers with public status
if litellm.public_mcp_servers is not None:
for server in redacted_mcp_servers:

View file

@ -769,8 +769,179 @@ class TestListToolsRestAPI:
assert captured["server"] is stub_server
assert result["tools"] == ["tool-1"]
assert result["error"] is None
async def test_non_admin_ui_session_resolves_as_admitted_subject(self, monkeypatch):
"""LIT-4861: a non-admin dashboard session must act as the admitted subject on this
route, so server reachability AND tool ceilings bind to the user's grants exactly as
they do for a gateway session, never to the bare session key."""
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
session_auth = UserAPIKeyAuth(
team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="grant-user", user_role="internal_user"
)
admitted_auth = UserAPIKeyAuth(user_id="grant-user", org_id="admitted-org")
async def fake_reload(user_id):
assert user_id == "grant-user"
return admitted_auth
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
fake_reload,
)
seen_server_resolution_auths = []
async def fake_get_allowed_mcp_servers(user_api_key_auth=None, **kwargs):
seen_server_resolution_auths.append(user_api_key_auth)
return ["server-1"]
class StubServer:
alias = "server-1"
server_name = "server-1"
name = "stub"
allowed_tools = None
mcp_info = {"server_name": "stub"}
available_on_public_internet = True
stub_server = StubServer()
captured = {}
async def fake_get_tools(
server,
server_auth_header,
raw_headers=None,
user_api_key_auth=None,
extra_headers=None,
apply_tool_filters=True,
):
captured["user_api_key_auth"] = user_api_key_auth
return ["tool-1"]
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_allowed_mcp_servers",
fake_get_allowed_mcp_servers,
raising=False,
)
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_mcp_server_by_id",
lambda server_id: stub_server if server_id == "server-1" else None,
raising=False,
)
monkeypatch.setattr(
rest_endpoints,
"_get_tools_for_single_server",
fake_get_tools,
raising=False,
)
request = _build_request(path="/mcp-rest/tools/list", method="GET")
result = await rest_endpoints.list_tool_rest_api(
request,
server_id="server-1",
user_api_key_dict=session_auth,
)
resolved = [*seen_server_resolution_auths, captured["user_api_key_auth"]]
assert seen_server_resolution_auths
assert all(a.org_id == "admitted-org" and a.team_id is None for a in resolved)
assert result["tools"] == ["tool-1"]
assert result["message"] == "Successfully retrieved tools"
async def test_toolset_scoped_request_keeps_the_caller_credential(self, monkeypatch):
"""LIT-4861: the admitted subject resolves per grant source and a team source deliberately
carries none of the caller's own object_permission, so a toolset narrowing layered on top
would evaporate on every team-granted server. A toolset-scoped request therefore stays on
the caller's own credential, exactly as it did before the acting-as-user swap."""
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
session_auth = UserAPIKeyAuth(
team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="grant-user", user_role="internal_user"
)
scoped_auth = UserAPIKeyAuth(
object_permission=LiteLLM_ObjectPermissionTable(
object_permission_id="toolset-scope",
mcp_servers=["toolset-server-1"],
)
)
reload_calls: list[str] = []
scope_inputs: list[UserAPIKeyAuth] = []
async def record_reload(user_id):
reload_calls.append(user_id)
return UserAPIKeyAuth(user_id=user_id)
class StubToolset:
toolset_id = "toolset-1"
class StubServer:
alias = "toolset-server-1"
server_name = "toolset-server-1"
name = "toolset-server-1"
allowed_tools = None
mcp_info = {"server_name": "toolset-server-1"}
available_on_public_internet = True
stub_server = StubServer()
async def fake_get_toolset_by_name_cached(prisma_client, toolset_name):
return StubToolset()
async def fake_apply_toolset_scope(user_api_key_auth, toolset_id):
scope_inputs.append(user_api_key_auth)
return scoped_auth
async def fake_get_allowed_mcp_servers(user_api_key_auth=None, **kwargs):
assert user_api_key_auth is scoped_auth
return ["toolset-server-1"]
async def fake_get_tools(server, server_auth_header, *args, **kwargs):
return ["toolset-tool-1"]
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
record_reload,
)
monkeypatch.setattr(
"litellm.proxy.utils.get_prisma_client_or_throw",
lambda *args, **kwargs: MagicMock(),
)
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_toolset_by_name_cached",
fake_get_toolset_by_name_cached,
raising=False,
)
monkeypatch.setattr(rest_endpoints, "_apply_toolset_scope", fake_apply_toolset_scope, raising=False)
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_allowed_mcp_servers",
fake_get_allowed_mcp_servers,
raising=False,
)
monkeypatch.setattr(
rest_endpoints.global_mcp_server_manager,
"get_mcp_server_by_id",
lambda server_id: stub_server if server_id == "toolset-server-1" else None,
raising=False,
)
monkeypatch.setattr(rest_endpoints, "_get_tools_for_single_server", fake_get_tools, raising=False)
request = _build_request(path="/mcp-rest/tools/list", method="GET")
result = await rest_endpoints.list_tool_rest_api(
request,
server_id=None,
toolset_name="research_tools",
user_api_key_dict=session_auth,
)
assert result["tools"] == ["toolset-tool-1"]
assert scope_inputs == [session_auth]
assert reload_calls == []
async def test_include_disabled_tools_is_admin_only(self, monkeypatch):
"""include_disabled_tools skips the allowlist filter only for PROXY_ADMIN;
a non-admin passing it stays filtered so the REST endpoint can't be used

View file

@ -3,6 +3,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
from litellm.proxy._types import UserAPIKeyAuth
@ -120,3 +121,140 @@ async def test_build_effective_auth_contexts_handles_unpicklable_parent_span(
assert contexts[0].team_id == "team-span"
assert contexts[0].parent_otel_span is parent_span
@pytest.mark.asyncio
async def test_build_effective_auth_contexts_appends_admitted_user_context(monkeypatch):
"""LIT-4861: the dashboard session must resolve with the user's admitted identity so the
page list and every per-server action endpoint see user-level grants the same way the
gateway session does."""
user_auth = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="user-42")
admitted_auth = UserAPIKeyAuth(user_id="user-42")
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.ui_session_utils.resolve_ui_session_team_ids",
AsyncMock(return_value=["team-one"]),
)
reload_mock = AsyncMock(return_value=admitted_auth)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
reload_mock,
)
contexts = await build_effective_auth_contexts(user_auth)
assert contexts[-1].user_id == "user-42" and contexts[-1].team_id is None
assert [ctx.team_id for ctx in contexts[:-1]] == ["team-one"]
reload_mock.assert_awaited_once_with("user-42")
@pytest.mark.asyncio
async def test_build_effective_auth_contexts_never_widens_caller_passed_keys(monkeypatch):
normal_user = UserAPIKeyAuth(team_id="regular-team", user_id="user-1")
reload_mock = AsyncMock()
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
reload_mock,
)
contexts = await build_effective_auth_contexts(normal_user)
assert contexts == [normal_user]
reload_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_build_effective_auth_contexts_survives_admitted_reload_failure(monkeypatch):
user_auth = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="user-9")
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.ui_session_utils.resolve_ui_session_team_ids",
AsyncMock(return_value=["team-a"]),
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
AsyncMock(side_effect=HTTPException(status_code=503, detail="db down")),
)
contexts = await build_effective_auth_contexts(user_auth)
assert [ctx.team_id for ctx in contexts] == ["team-a"]
@pytest.mark.asyncio
async def test_acting_user_auth_returns_admitted_subject_for_non_admin_sessions(monkeypatch):
"""LIT-4861: acting-as-user MCP routes must resolve a non-admin dashboard session as the
admitted subject so tool ceilings, reachability, and limits bind exactly as on /mcp."""
from litellm.proxy._experimental.mcp_server.ui_session_utils import acting_user_auth
user_auth = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="user-42", user_role="internal_user")
admitted_auth = UserAPIKeyAuth(user_id="user-42")
reload_mock = AsyncMock(return_value=admitted_auth)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
reload_mock,
)
result = await acting_user_auth(user_auth)
assert result.user_id == "user-42" and result.team_id is None
reload_mock.assert_awaited_once_with("user-42")
@pytest.mark.asyncio
async def test_acting_user_auth_keeps_admin_sessions_and_passed_keys_unchanged(monkeypatch):
from litellm.proxy._experimental.mcp_server.ui_session_utils import acting_user_auth
reload_mock = AsyncMock()
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
reload_mock,
)
admin_session = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="admin-1", user_role="proxy_admin")
assert await acting_user_auth(admin_session) is admin_session
passed_key = UserAPIKeyAuth(team_id="regular-team", user_id="user-1", user_role="internal_user")
assert await acting_user_auth(passed_key) is passed_key
reload_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_acting_user_auth_falls_back_to_session_auth_on_reload_failure(monkeypatch):
from litellm.proxy._experimental.mcp_server.ui_session_utils import acting_user_auth
user_auth = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="user-9", user_role="internal_user")
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
AsyncMock(side_effect=HTTPException(status_code=503, detail="db down")),
)
assert await acting_user_auth(user_auth) is user_auth
@pytest.mark.asyncio
async def test_admitted_user_context_carries_the_request_span(monkeypatch):
"""Swapping the principal must not drop the request: the admitted subject is rebuilt from the
user row and carries no span of its own, so every consumer would otherwise lose trace linkage
for the resolution and logging it drives."""
from litellm.proxy._experimental.mcp_server.ui_session_utils import acting_user_auth
class DummySpan:
def __init__(self) -> None:
self._lock = threading.RLock()
parent_span = DummySpan()
user_auth = UserAPIKeyAuth(
team_id=UI_SESSION_TOKEN_TEAM_ID,
user_id="user-42",
user_role="internal_user",
parent_otel_span=parent_span,
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
AsyncMock(return_value=UserAPIKeyAuth(user_id="user-42")),
)
assert (await acting_user_auth(user_auth)).parent_otel_span is parent_span
assert (await build_effective_auth_contexts(user_auth))[-1].parent_otel_span is parent_span

View file

@ -6138,3 +6138,255 @@ def test_bundled_openapi_registry_parses_and_entries_are_well_formed():
)
for tool in entry.get("key_tools", []):
assert tool.get("name") and tool.get("description"), f"{entry['name']}: malformed key_tool"
class TestConnectedAppViewAnnotation:
"""LIT-4861: GET /v1/mcp/server?connected_app_view=true must annotate each server with
whether the caller's gateway OAuth sessions (connected apps) are served it on /mcp.
The view is honored only for the dashboard's UI session credential; a caller-passed
virtual key must never be widened to its owning user's identity."""
def _ui_session_auth(self, user_role: LitellmUserRoles = LitellmUserRoles.PROXY_ADMIN) -> UserAPIKeyAuth:
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
return generate_mock_user_api_key_auth(user_role=user_role, team_id=UI_SESSION_TOKEN_TEAM_ID)
def _mock_manager(self, servers, reachable_ids):
mock_manager = MagicMock()
mock_manager.get_all_allowed_mcp_servers = AsyncMock(return_value=servers)
mock_manager.get_all_mcp_servers_unfiltered = AsyncMock(return_value=servers)
mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=reachable_ids)
return mock_manager
def _servers(self):
return [
generate_mock_mcp_server_db_record(server_id="server-1", alias="Granted"),
generate_mock_mcp_server_db_record(server_id="server-2", alias="Ungranted"),
]
@pytest.mark.asyncio
async def test_connected_app_view_annotates_reachability_via_admitted_resolver(self):
caller_auth = self._ui_session_auth()
admitted_auth = UserAPIKeyAuth(user_id="test_user_id")
admitted_auth.mcp_admitted_user_subject = True
mock_manager = self._mock_manager(self._servers(), ["server-1"])
reload_mock = AsyncMock(return_value=admitted_auth)
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts",
AsyncMock(return_value=[caller_auth]),
),
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
reload_mock,
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
result = await fetch_all_mcp_servers(user_api_key_dict=caller_auth, connected_app_view=True)
flags = {server.server_id: server.connected_app_reachable for server in result}
assert flags == {"server-1": True, "server-2": False}
reload_mock.assert_awaited_once_with("test_user_id")
mock_manager.get_allowed_mcp_servers.assert_awaited_once_with(admitted_auth)
@pytest.mark.asyncio
async def test_connected_app_view_stamps_view_all_list_and_survives_non_admin_sanitizer(self):
"""view_all preempts the manager's admin shortcut with a second whole-registry
shortcut; the annotation must still land, and must survive the non-admin sanitizer."""
caller_auth = self._ui_session_auth(user_role=LitellmUserRoles.INTERNAL_USER)
mock_manager = self._mock_manager(self._servers(), ["server-2"])
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_user_mcp_management_mode",
return_value="view_all",
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
AsyncMock(return_value=UserAPIKeyAuth(user_id="test_user_id")),
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
result = await fetch_all_mcp_servers(user_api_key_dict=caller_auth, connected_app_view=True)
mock_manager.get_all_mcp_servers_unfiltered.assert_awaited_once()
flags = {server.server_id: server.connected_app_reachable for server in result}
assert flags == {"server-1": False, "server-2": True}
@pytest.mark.asyncio
async def test_connected_app_view_lists_user_granted_servers_via_admitted_context(self):
"""A server granted only through the user's own object permission must be listed and
flagged reachable: the REAL build_effective_auth_contexts appends the admitted-user
context, so the page and every action endpoint resolve it identically."""
caller_auth = self._ui_session_auth(user_role=LitellmUserRoles.INTERNAL_USER)
admitted_auth = UserAPIKeyAuth(user_id="test_user_id", org_id="admitted-org")
listed_row = generate_mock_mcp_server_db_record(server_id="server-1", alias="TeamGranted")
user_granted_row = generate_mock_mcp_server_db_record(server_id="server-2", alias="UserGranted")
async def per_context_servers(user_api_key_auth=None):
if user_api_key_auth is not None and user_api_key_auth.org_id == "admitted-org":
return [listed_row, user_granted_row]
return [listed_row]
mock_manager = MagicMock()
mock_manager.get_all_allowed_mcp_servers = AsyncMock(side_effect=per_context_servers)
mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["server-1", "server-2"])
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
patch(
"litellm.proxy._experimental.mcp_server.ui_session_utils.resolve_ui_session_team_ids",
AsyncMock(return_value=[]),
),
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
AsyncMock(return_value=admitted_auth),
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
result = await fetch_all_mcp_servers(user_api_key_dict=caller_auth, connected_app_view=True)
flags = {server.server_id: server.connected_app_reachable for server in result}
assert flags == {"server-1": True, "server-2": True}
@pytest.mark.asyncio
async def test_connected_app_view_fails_closed_when_admitted_reload_fails(self):
caller_auth = self._ui_session_auth()
mock_manager = self._mock_manager(self._servers(), ["server-1"])
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts",
AsyncMock(return_value=[caller_auth]),
),
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
AsyncMock(side_effect=HTTPException(status_code=401, detail="expired")),
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
result = await fetch_all_mcp_servers(user_api_key_dict=caller_auth, connected_app_view=True)
assert all(server.connected_app_reachable is False for server in result)
@pytest.mark.asyncio
async def test_connected_app_view_off_leaves_field_unset(self):
caller_auth = generate_mock_user_api_key_auth()
mock_manager = self._mock_manager(self._servers(), ["server-1"])
reload_mock = AsyncMock(return_value=UserAPIKeyAuth(user_id="test_user_id"))
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts",
AsyncMock(return_value=[caller_auth]),
),
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
reload_mock,
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
result = await fetch_all_mcp_servers(user_api_key_dict=caller_auth)
assert all(server.connected_app_reachable is None for server in result)
reload_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_connected_app_view_userless_ui_credential_leaves_field_unset(self):
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
caller_auth = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="test_api_key", team_id=UI_SESSION_TOKEN_TEAM_ID
)
caller_auth.user_id = None
mock_manager = self._mock_manager(self._servers(), ["server-1"])
reload_mock = AsyncMock(return_value=UserAPIKeyAuth(user_id="test_user_id"))
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts",
AsyncMock(return_value=[caller_auth]),
),
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
reload_mock,
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
result = await fetch_all_mcp_servers(user_api_key_dict=caller_auth, connected_app_view=True)
assert all(server.connected_app_reachable is None for server in result)
reload_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_connected_app_view_ignored_for_caller_passed_virtual_keys(self):
"""A virtual key the user passes themselves is never widened to the owning user's
identity: the view param is a no-op and the admitted resolver is never consulted."""
caller_auth = generate_mock_user_api_key_auth(team_id="some-real-team")
mock_manager = self._mock_manager(self._servers(), ["server-1"])
reload_mock = AsyncMock(return_value=UserAPIKeyAuth(user_id="test_user_id"))
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.build_effective_auth_contexts",
AsyncMock(return_value=[caller_auth]),
),
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._reload_admitted_user",
reload_mock,
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
result = await fetch_all_mcp_servers(user_api_key_dict=caller_auth, connected_app_view=True)
assert all(server.connected_app_reachable is None for server in result)
reload_mock.assert_not_awaited()

View file

@ -2729,7 +2729,7 @@
"count": 1
},
"no-nested-ternary": {
"count": 6
"count": 4
}
},
"src/components/chat/MCPConnectPicker.tsx": {

View file

@ -1,6 +1,6 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import MCPAppsPanel from "./MCPAppsPanel";
import { fetchMCPServers, listMCPTools } from "../networking";
@ -86,3 +86,208 @@ describe("MCPAppsPanel logos", () => {
expect(screen.getByAltText("local_logo logo").getAttribute("src")).toBe("/litellm/ui/assets/logos/github.svg");
});
});
const connectServers = [
{
server_id: "s-reach",
server_name: "reachable_srv",
auth_type: "none",
connected_app_reachable: true,
},
{
server_id: "s-unreach",
server_name: "unreachable_srv",
auth_type: "none",
connected_app_reachable: false,
},
] as MCPServer[];
const renderConnectPanel = (connectMode: boolean, selectedServers: string[] = []) =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MCPAppsPanel accessToken="tok" selectedServers={selectedServers} onChange={vi.fn()} connectMode={connectMode} />
</QueryClientProvider>,
);
describe("MCPAppsPanel connected-app reachability (LIT-4861)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("requests the connected-app view and hides unreachable servers in connect mode", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderConnectPanel(true, ["reachable_srv", "unreachable_srv"]);
expect(await screen.findByText("reachable_srv")).toBeInTheDocument();
expect(vi.mocked(fetchMCPServers)).toHaveBeenCalledWith("tok", undefined, true);
expect(screen.queryByText("unreachable_srv")).not.toBeInTheDocument();
expect(screen.getByText("Connected (1)")).toBeInTheDocument();
const toolCountFetchedIds = vi.mocked(listMCPTools).mock.calls.map((call) => call[1]);
expect(toolCountFetchedIds).toContain("s-reach");
expect(toolCountFetchedIds).not.toContain("s-unreach");
});
it("blocks connecting an unsupported server from the detail view in connect mode", async () => {
const detailServers = [
...connectServers,
{
server_id: "s-unsup",
server_name: "unsupported_srv",
auth_type: "oauth2_token_exchange",
connected_app_reachable: true,
},
] as MCPServer[];
vi.mocked(fetchMCPServers).mockResolvedValue(detailServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderConnectPanel(true);
fireEvent.click(await screen.findByText("unsupported_srv"));
expect(await screen.findByRole("heading", { name: "unsupported_srv" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Connect" })).not.toBeInTheDocument();
expect(screen.getByText("Not supported on this connection")).toBeInTheDocument();
});
it("keeps the detail-view Connect action outside connect mode", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderConnectPanel(false);
fireEvent.click(await screen.findByText("unreachable_srv"));
expect(await screen.findByRole("heading", { name: "unreachable_srv" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
});
it("ignores the flag and skips no server outside connect mode", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderConnectPanel(false, ["reachable_srv", "unreachable_srv"]);
expect(await screen.findByText("unreachable_srv")).toBeInTheDocument();
expect(vi.mocked(fetchMCPServers)).toHaveBeenCalledWith("tok", undefined, false);
expect(screen.queryByText("Not available to connected apps")).not.toBeInTheDocument();
expect(screen.getByText("Connected (2)")).toBeInTheDocument();
const toolCountFetchedIds = vi.mocked(listMCPTools).mock.calls.map((call) => call[1]);
expect(toolCountFetchedIds).toContain("s-unreach");
});
const revocable = (reachable: boolean) =>
[
{ server_id: "s-reach", server_name: "reachable_srv", auth_type: "none", connected_app_reachable: true },
{ server_id: "s-drop", server_name: "revoked_srv", auth_type: "none", connected_app_reachable: reachable },
] as MCPServer[];
const ConnectPanel = ({
token,
onChange,
client,
}: {
token: string;
onChange: (servers: string[]) => void;
client: QueryClient;
}) => (
<QueryClientProvider client={client}>
<MCPAppsPanel accessToken={token} selectedServers={[]} onChange={onChange} connectMode />
</QueryClientProvider>
);
const newClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
it("drops an open detail view when a refetch removes that server from the reachable set", async () => {
vi.mocked(fetchMCPServers).mockResolvedValueOnce(revocable(true)).mockResolvedValueOnce(revocable(false));
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const client = newClient();
const { rerender } = render(<ConnectPanel token="tok" onChange={vi.fn()} client={client} />);
fireEvent.click(await screen.findByText("revoked_srv"));
expect(await screen.findByRole("heading", { name: "revoked_srv" })).toBeInTheDocument();
rerender(<ConnectPanel token="tok-refreshed" onChange={vi.fn()} client={client} />);
await waitFor(() => expect(screen.queryByRole("heading", { name: "revoked_srv" })).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Connect" })).not.toBeInTheDocument();
expect(screen.queryByText("revoked_srv")).not.toBeInTheDocument();
expect(screen.getByText("reachable_srv")).toBeInTheDocument();
});
it("does not select a server whose Connect finishes after a refetch removed it", async () => {
vi.mocked(fetchMCPServers).mockResolvedValueOnce(revocable(true)).mockResolvedValueOnce(revocable(false));
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const onChange = vi.fn();
const client = newClient();
const { rerender } = render(<ConnectPanel token="tok" onChange={onChange} client={client} />);
fireEvent.click(await screen.findByText("revoked_srv"));
expect(await screen.findByRole("heading", { name: "revoked_srv" })).toBeInTheDocument();
let finishConnect: (result: { tools: never[] }) => void = () => {};
vi.mocked(listMCPTools).mockImplementationOnce(() => new Promise((resolve) => (finishConnect = resolve)));
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
rerender(<ConnectPanel token="tok-refreshed" onChange={onChange} client={client} />);
await waitFor(() => expect(screen.queryByRole("heading", { name: "revoked_srv" })).not.toBeInTheDocument());
await act(async () => {
finishConnect({ tools: [] });
});
expect(onChange).not.toHaveBeenCalled();
expect(screen.queryByText("revoked_srv")).not.toBeInTheDocument();
expect(screen.getByText("Connected", { exact: false }).textContent).toBe("Connected");
});
it("does not select a server when Connect resolves in the same tick the refetch drops it", async () => {
let finishRefetch: (servers: MCPServer[]) => void = () => {};
vi.mocked(fetchMCPServers)
.mockResolvedValueOnce(revocable(true))
.mockImplementationOnce(() => new Promise((resolve) => (finishRefetch = resolve)));
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const onChange = vi.fn();
const client = newClient();
const { rerender } = render(<ConnectPanel token="tok" onChange={onChange} client={client} />);
fireEvent.click(await screen.findByText("revoked_srv"));
expect(await screen.findByRole("heading", { name: "revoked_srv" })).toBeInTheDocument();
let finishConnect: (result: { tools: never[] }) => void = () => {};
vi.mocked(listMCPTools).mockImplementationOnce(() => new Promise((resolve) => (finishConnect = resolve)));
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
rerender(<ConnectPanel token="tok-refreshed" onChange={onChange} client={client} />);
await act(async () => {
finishRefetch(revocable(false));
finishConnect({ tools: [] });
});
expect(onChange).not.toHaveBeenCalled();
expect(screen.queryByText("revoked_srv")).not.toBeInTheDocument();
});
it("does not let a superseded list load overwrite the current reachable set", async () => {
let finishStaleLoad: (servers: MCPServer[]) => void = () => {};
vi.mocked(fetchMCPServers)
.mockImplementationOnce(() => new Promise((resolve) => (finishStaleLoad = resolve)))
.mockResolvedValueOnce(revocable(false));
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const client = newClient();
const { rerender } = render(<ConnectPanel token="tok" onChange={vi.fn()} client={client} />);
rerender(<ConnectPanel token="tok-refreshed" onChange={vi.fn()} client={client} />);
expect(await screen.findByText("reachable_srv")).toBeInTheDocument();
await act(async () => {
finishStaleLoad(revocable(true));
});
expect(screen.queryByText("revoked_srv")).not.toBeInTheDocument();
});
});

View file

@ -103,16 +103,17 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
const [detailServer, setDetailServer] = useState<MCPServer | null>(null);
const [detailServerId, setDetailServerId] = useState<string | null>(null);
const [toolCounts, setToolCounts] = useState<Record<string, number>>({});
const [loadingCounts, setLoadingCounts] = useState(false);
const [oauthConnected, setOauthConnected] = useState<Set<string>>(new Set());
const [oauthChecking, setOauthChecking] = useState<Set<string>>(new Set());
const serversRef = useRef<MCPServer[]>([]);
useEffect(() => {
serversRef.current = servers;
}, [servers]);
const commitServers = useCallback((next: MCPServer[]) => {
serversRef.current = next;
setServers(next);
}, []);
const selectedServersRef = useRef<string[]>(selectedServers);
useEffect(() => {
selectedServersRef.current = selectedServers;
@ -124,13 +125,30 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
const nameOf = (s: MCPServer) => s.server_name ?? s.alias ?? s.server_id;
const fetchLoadCancelledRef = useRef(false);
const detailServer = servers.find((s) => s.server_id === detailServerId);
const connectUnavailabilityLabel = useCallback(
(s: MCPServer): string | null => {
if (!connectMode) return null;
if (isUnsupportedOnGatewayConnect(s.auth_type)) return "Not supported on this connection";
return null;
},
[connectMode],
);
const connectableNow = useCallback(
(serverId: string): MCPServer | undefined => {
const current = serversRef.current.find((s) => s.server_id === serverId);
return current !== undefined && connectUnavailabilityLabel(current) === null ? current : undefined;
},
[connectUnavailabilityLabel],
);
const fetchToolCount = useCallback(
async (server: MCPServer) => {
async (server: MCPServer, isCurrentLoad: () => boolean) => {
try {
const toolsData = await listMCPTools(accessToken, server.server_id);
if (fetchLoadCancelledRef.current) return;
if (!isCurrentLoad()) return;
const tools: MCPTool[] = Array.isArray(toolsData?.tools) ? toolsData.tools : [];
setToolCounts((prev) => ({ ...prev, [nameOf(server)]: tools.length }));
} catch {
@ -141,17 +159,17 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
);
const checkOauthCredential = useCallback(
async (server: MCPServer) => {
async (server: MCPServer, isCurrentLoad: () => boolean) => {
try {
const status = await getMCPOAuthUserCredentialStatus(accessToken, server.server_id);
if (fetchLoadCancelledRef.current) return;
if (!isCurrentLoad()) return;
if (status.has_credential && !status.is_expired) {
setOauthConnected((prev) => new Set(prev).add(server.server_id));
}
} catch {
// ignore
} finally {
if (!fetchLoadCancelledRef.current) {
if (isCurrentLoad()) {
setOauthChecking((prev) => {
const next = new Set(prev);
next.delete(server.server_id);
@ -164,70 +182,77 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
);
useEffect(() => {
fetchLoadCancelledRef.current = false;
let current = true;
const isCurrentLoad = () => current;
fetchMCPServers(accessToken)
fetchMCPServers(accessToken, undefined, connectMode)
.then(async (serverData) => {
if (fetchLoadCancelledRef.current) return;
if (!isCurrentLoad()) return;
const list: MCPServer[] = Array.isArray(serverData) ? serverData : serverData?.data ?? [];
const oauthServers = list.filter((s) => s.auth_type === AUTH_TYPE.OAUTH2);
setServers(list);
const reachable = connectMode ? list.filter((s) => s.connected_app_reachable !== false) : list;
const oauthServers = reachable.filter((s) => s.auth_type === AUTH_TYPE.OAUTH2);
commitServers(reachable);
setOauthChecking(new Set(oauthServers.map((s) => s.server_id)));
setLoading(false);
oauthServers.forEach((s) => checkOauthCredential(s));
oauthServers.forEach((s) => checkOauthCredential(s, isCurrentLoad));
setLoadingCounts(true);
const chunks = Array.from({ length: Math.ceil(list.length / TOOLS_FETCH_CONCURRENCY) }, (_, i) =>
list.slice(i * TOOLS_FETCH_CONCURRENCY, (i + 1) * TOOLS_FETCH_CONCURRENCY),
const chunks = Array.from({ length: Math.ceil(reachable.length / TOOLS_FETCH_CONCURRENCY) }, (_, i) =>
reachable.slice(i * TOOLS_FETCH_CONCURRENCY, (i + 1) * TOOLS_FETCH_CONCURRENCY),
);
for (const chunk of chunks) {
if (fetchLoadCancelledRef.current) return;
await Promise.allSettled(chunk.map((s) => fetchToolCount(s)));
if (!isCurrentLoad()) return;
await Promise.allSettled(chunk.map((s) => fetchToolCount(s, isCurrentLoad)));
}
if (!fetchLoadCancelledRef.current) setLoadingCounts(false);
if (isCurrentLoad()) setLoadingCounts(false);
})
.catch(() => {
if (!fetchLoadCancelledRef.current) {
setServers([]);
if (isCurrentLoad()) {
commitServers([]);
setLoading(false);
}
});
return () => {
fetchLoadCancelledRef.current = true;
current = false;
};
}, [accessToken, fetchToolCount, checkOauthCredential]);
}, [accessToken, connectMode, commitServers, fetchToolCount, checkOauthCredential]);
useEffect(() => {
if (oauthConnected.size === 0) return;
const namesToAdd = serversRef.current
.filter((s) => oauthConnected.has(s.server_id) && !selectedServersRef.current.includes(nameOf(s)))
.filter(
(s) =>
oauthConnected.has(s.server_id) &&
!selectedServersRef.current.includes(nameOf(s)) &&
connectUnavailabilityLabel(s) === null,
)
.map(nameOf);
if (namesToAdd.length > 0) {
onChangeRef.current([...selectedServersRef.current, ...namesToAdd]);
}
}, [oauthConnected]);
}, [oauthConnected, connectUnavailabilityLabel]);
const handleToggle = async (serverName: string, checked: boolean, serverId?: string) => {
const handleToggle = async (server: MCPServer, checked: boolean) => {
const serverName = nameOf(server);
if (!checked) {
onChange(selectedServers.filter((s) => s !== serverName));
if (serverId) {
setOauthConnected((prev) => {
const next = new Set(prev);
next.delete(serverId);
return next;
});
}
setOauthConnected((prev) => {
const next = new Set(prev);
next.delete(server.server_id);
return next;
});
return;
}
if (connectableNow(server.server_id) === undefined) return;
setTogglingOn((prev) => new Set(prev).add(serverName));
try {
const idToFetch = serverId ?? serverName;
const result = await listMCPTools(accessToken, idToFetch);
const result = await listMCPTools(accessToken, server.server_id);
if (result?.error) {
MessageManager.warning(`Could not load tools for ${serverName}`);
return;
}
if (connectableNow(server.server_id) === undefined) return;
if (!selectedServersRef.current.includes(serverName)) {
onChange([...selectedServersRef.current, serverName]);
}
@ -243,11 +268,10 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
};
const renderConnectionIndicator = (server: MCPServer) => {
if (connectMode && isUnsupportedOnGatewayConnect(server.auth_type)) {
const unavailabilityLabel = connectUnavailabilityLabel(server);
if (unavailabilityLabel !== null) {
return (
<span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap">
Not supported on this connection
</span>
<span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap">{unavailabilityLabel}</span>
);
}
if (server.auth_type === AUTH_TYPE.OAUTH2) {
@ -285,11 +309,23 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
!query.trim() ||
name.toLowerCase().includes(query.toLowerCase()) ||
(s.description ?? "").toLowerCase().includes(query.toLowerCase());
const matchesTab = activeTab === "all" || selectedServers.includes(name);
const matchesTab =
activeTab === "all" || (selectedServers.includes(name) && connectUnavailabilityLabel(s) === null);
return matchesQuery && matchesTab;
});
const connectedCount = servers.filter((s) => selectedServers.includes(nameOf(s))).length;
const connectedCount = servers.filter(
(s) => selectedServers.includes(nameOf(s)) && connectUnavailabilityLabel(s) === null,
).length;
const emptyStateText = () => {
if (servers.length === 0) {
return connectMode
? "No MCP servers are available to this connection yet. Ask an admin to grant your user or team access."
: "No MCP servers configured. Add servers in Tools -> MCP Servers.";
}
return activeTab === "connected" ? "No servers connected yet." : "No servers match your search.";
};
const totalTools = Object.values(toolCounts).reduce((sum, n) => sum + n, 0);
if (detailServer) {
@ -298,12 +334,65 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
const isTogglingOn = togglingOn.has(name);
const color = getAvatarColor(name);
const renderDetailAction = () => {
const unavailabilityLabel = connectUnavailabilityLabel(detailServer);
if (unavailabilityLabel !== null) {
return <span className="text-[13px] text-muted-foreground py-2.5 shrink-0">{unavailabilityLabel}</span>;
}
if (detailServer.auth_type !== AUTH_TYPE.OAUTH2) {
return (
<Button
variant={isConnected ? "outline" : "default"}
disabled={isTogglingOn}
onClick={() => handleToggle(detailServer, !isConnected)}
className="font-semibold h-[38px] min-w-[110px]"
>
{isTogglingOn && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
{isConnected ? "Disconnect" : "Connect"}
</Button>
);
}
if (oauthConnected.has(detailServer.server_id)) {
return (
<Button
variant="destructive"
onClick={async () => {
try {
await deleteMCPOAuthUserCredential(accessToken, detailServer.server_id);
} catch (_) {
// Ignore
}
setOauthConnected((prev) => {
const n = new Set(prev);
n.delete(detailServer.server_id);
return n;
});
onChangeRef.current(selectedServersRef.current.filter((s) => s !== name));
}}
className="font-semibold h-[38px] min-w-[110px]"
>
Disconnect
</Button>
);
}
return (
<OAuth2ConnectButton
server={detailServer}
accessToken={accessToken}
onConnect={(id) => {
setOauthConnected((prev) => new Set(prev).add(id));
}}
variant="button"
/>
);
};
return (
<div className="w-full">
<Button
variant="ghost"
size="sm"
onClick={() => setDetailServer(null)}
onClick={() => setDetailServerId(null)}
className="-ml-3 mb-5 gap-1.5 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
@ -329,48 +418,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
<h2 className="m-0 mb-1 text-[22px] font-bold text-foreground">{name}</h2>
<p className="m-0 text-sm text-muted-foreground">{detailServer.description ?? "MCP server"}</p>
</div>
{detailServer.auth_type === AUTH_TYPE.OAUTH2 ? (
oauthConnected.has(detailServer.server_id) ? (
<Button
variant="destructive"
onClick={async () => {
try {
await deleteMCPOAuthUserCredential(accessToken, detailServer.server_id);
} catch (_) {
// Ignore
}
setOauthConnected((prev) => {
const n = new Set(prev);
n.delete(detailServer.server_id);
return n;
});
onChangeRef.current(selectedServersRef.current.filter((s) => s !== name));
}}
className="font-semibold h-[38px] min-w-[110px]"
>
Disconnect
</Button>
) : (
<OAuth2ConnectButton
server={detailServer}
accessToken={accessToken}
onConnect={(id) => {
setOauthConnected((prev) => new Set(prev).add(id));
}}
variant="button"
/>
)
) : (
<Button
variant={isConnected ? "outline" : "default"}
disabled={isTogglingOn}
onClick={() => handleToggle(name, !isConnected, detailServer.server_id)}
className="font-semibold h-[38px] min-w-[110px]"
>
{isTogglingOn && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
{isConnected ? "Disconnect" : "Connect"}
</Button>
)}
{renderDetailAction()}
</div>
<h3 className="m-0 mb-3 text-[15px] font-semibold text-foreground">Information</h3>
@ -494,13 +542,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center text-muted-foreground text-[13px] py-12 px-3">
{servers.length === 0
? "No MCP servers configured. Add servers in Tools -> MCP Servers."
: activeTab === "connected"
? "No servers connected yet."
: "No servers match your search."}
</div>
<div className="text-center text-muted-foreground text-[13px] py-12 px-3">{emptyStateText()}</div>
) : (
<div className="grid grid-cols-2 border rounded-lg overflow-hidden">
{filtered.map((server, idx) => {
@ -508,16 +550,16 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
const color = getAvatarColor(name);
const isLeftCol = idx % 2 === 0;
const count = toolCounts[name];
const unsupported = !!connectMode && isUnsupportedOnGatewayConnect(server.auth_type);
const unavailable = connectUnavailabilityLabel(server) !== null;
return (
<div
key={server.server_id}
onClick={() => setDetailServer(server)}
onClick={() => setDetailServerId(server.server_id)}
className={`flex items-center gap-3 p-4 bg-card cursor-pointer transition-colors hover:bg-accent/30 min-w-0 ${
isLeftCol ? "border-r" : ""
} ${Math.floor(idx / 2) < Math.floor((filtered.length - 1) / 2) ? "border-b" : ""} ${
unsupported ? "opacity-50" : ""
unavailable ? "opacity-50" : ""
}`}
>
{server.mcp_info?.logo_url ? (

View file

@ -436,6 +436,7 @@ export interface MCPServer {
byok_description?: string[] | null;
byok_api_key_help_url?: string | null;
has_user_credential?: boolean | null;
connected_app_reachable?: boolean | null;
/** GitHub / source repository URL */
source_url?: string | null;

View file

@ -4751,9 +4751,12 @@ export const fetchDiscoverableMCPServers = async (accessToken: string) => {
}
};
export const fetchMCPServers = async (accessToken: string, teamId?: string | null) => {
export const fetchMCPServers = async (accessToken: string, teamId?: string | null, connectedAppView?: boolean) => {
try {
return await apiClient.get(`/v1/mcp/server`, { accessToken, query: { team_id: teamId || undefined } });
return await apiClient.get(`/v1/mcp/server`, {
accessToken,
query: { team_id: teamId || undefined, connected_app_view: connectedAppView || undefined },
});
} catch (error) {
console.error("Failed to fetch MCP servers:", error);
throw error;