mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
d0fe305810
commit
4d2b7224fd
12 changed files with 1015 additions and 112 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -2729,7 +2729,7 @@
|
|||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 6
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/chat/MCPConnectPicker.tsx": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue