fix(mcp): give the MCP client exactly the servers left on in the connect page (#50973)

This commit is contained in:
shrey 2026-11-13 16:36:49 -08:00 • committed by GitHub
parent c4a72b8fac
commit 68d6db45ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2011 additions and 424 deletions

View file

@ -39630,6 +39630,14 @@
"title": "Flow",
"type": "string"
},
"server": {
"default": [],
"items": {
"type": "string"
},
"title": "Server",
"type": "array"
},
"team_id": {
"anyOf": [
{
@ -42205,7 +42213,7 @@
},
"/authorize/complete": {
"post": {
"description": "Finish an aggregate connect flow: mint the gateway authorization code for the\nsigned-in user and hand it back to the DCR client, by 303 redirect (default) or, for\na loopback client on a different machine, as a copyable callback URL\n(``delivery=manual``). POST plus the per-flow HttpOnly cookie set at /authorize; an\nanonymous or bad-flow request just 400s. The native-client consent page adds\n``decision`` (approve or deny) and the ``team_id`` the credential is attributed to.",
"description": "Finish an aggregate connect flow: mint the gateway authorization code for the\nsigned-in user and hand it back to the DCR client, by 303 redirect (default) or, for\na loopback client on a different machine, as a copyable callback URL\n(``delivery=manual``). POST plus the per-flow HttpOnly cookie set at /authorize; an\nanonymous or bad-flow request just 400s. The native-client consent page adds\n``decision`` (approve or deny) and the ``team_id`` the credential is attributed to.\nEach ``server`` field is the id of a server the connect page shows as on.",
"operationId": "authorize_complete_authorize_complete_post",
"requestBody": {
"content": {

View file

@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import (
TYPE_CHECKING,
Annotated,
Final,
Literal,
Optional,
@ -2614,14 +2615,19 @@ async def authorize_complete(
delivery: str | None = Form(None),
team_id: str | None = Form(None),
decision: str | None = Form(None),
server: Annotated[tuple[str, ...], Form()] = (),
) -> Response:
"""Finish an aggregate connect flow: mint the gateway authorization code for the
signed-in user and hand it back to the DCR client, by 303 redirect (default) or, for
a loopback client on a different machine, as a copyable callback URL
(``delivery=manual``). POST plus the per-flow HttpOnly cookie set at /authorize; an
anonymous or bad-flow request just 400s. The native-client consent page adds
``decision`` (approve or deny) and the ``team_id`` the credential is attributed to."""
from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 # circular import at module load
``decision`` (approve or deny) and the ``team_id`` the credential is attributed to.
Each ``server`` field is the id of a server the connect page shows as on."""
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load
master_key,
user_api_key_cache,
)
return await complete_connect_flow(
request=request,
@ -2633,6 +2639,8 @@ async def authorize_complete(
decision=decision,
lookup_vendor_credential=_vendor_credential_state,
lookup_server_reachability=_user_can_reach_mcp_server,
selected_server_ids=server,
master_key=master_key,
)

View file

@ -411,6 +411,7 @@ class _ConnectFlow(_ServerScopedSealedModel):
audience: SessionAudience | None = None
requested_scopes: tuple[str, ...] = ()
upstream_scopes: tuple[UpstreamScope, ...] = ()
unresolved_resource: Literal[True] | None = None
class _GatewayAuthCode(_ServerScopedSealedModel):
@ -756,6 +757,9 @@ def aggregate_authorize(
if resource_scoped_server is not None and not upstream_scopes
else (),
upstream_scopes=upstream_scopes,
unresolved_resource=True
if resource is not None and resource_scoped_server is None and not _is_aggregate_resource(request, resource)
else None,
)
connect_url: Final = _append_query_params(f"{base_url}/ui/connect", (("connect_flow", handle),))
response: Final = RedirectResponse(connect_url, status_code=303)
@ -919,6 +923,7 @@ def _new_connect_flow(
audience: SessionAudience | None,
requested_scopes: tuple[str, ...] = (),
upstream_scopes: tuple[UpstreamScope, ...] = (),
unresolved_resource: Literal[True] | None = None,
) -> _ConnectFlow:
now: Final = datetime.now(timezone.utc)
return _ConnectFlow(
@ -933,6 +938,7 @@ def _new_connect_flow(
audience=audience,
requested_scopes=requested_scopes,
upstream_scopes=upstream_scopes,
unresolved_resource=unresolved_resource,
)
@ -1028,6 +1034,8 @@ class ConnectFlowDescription(TypedDict):
connected: ReadOnly[bool | None]
servers: ReadOnly[Sequence[ConnectFlowServer] | None]
requested_scopes: NotRequired[ReadOnly[tuple[str, ...]]]
selectable: NotRequired[ReadOnly[Literal[False]]]
required_server_ids: NotRequired[ReadOnly[tuple[str, ...]]]
async def _describe_connect_flow_server(
@ -1174,6 +1182,17 @@ async def _describe_upstream_scope_flow(
return descriptions[0]
def _with_selection_limits(flow: _ConnectFlow, described: ConnectFlowDescription) -> ConnectFlowDescription:
if flow.unresolved_resource:
unselectable: Final[ConnectFlowDescription] = {**described, "selectable": False}
return unselectable
if not flow.upstream_scopes:
return described
required: Final = tuple(dict.fromkeys(item.server_id for item in flow.upstream_scopes))
with_required: Final[ConnectFlowDescription] = {**described, "required_server_ids": required}
return with_required
async def describe_connect_flow(
request: Request,
flow_handle: str,
@ -1188,7 +1207,7 @@ async def describe_connect_flow(
return (
described
if isinstance(described, Response)
else JSONResponse(content=described, headers=TOKEN_NO_CACHE_HEADERS)
else JSONResponse(content=_with_selection_limits(opened, described), headers=TOKEN_NO_CACHE_HEADERS)
)
@ -1202,6 +1221,8 @@ async def complete_connect_flow(
decision: str | None = None,
lookup_vendor_credential: LookupVendorCredential = _unavailable_vendor_credential,
lookup_server_reachability: LookupServerReachability = _unreachable_server,
selected_server_ids: Sequence[str] = (),
master_key: str | None = None,
) -> Response:
"""Mint the code only after a deliberate POST by the sealed user.
@ -1227,6 +1248,13 @@ async def complete_connect_flow(
return _oauth_error(400, "invalid_request", "authorize the requested MCP server before finishing")
if described["connected"] is False:
return _oauth_error(400, "invalid_request", "authorize the requested MCP server before finishing")
narrowed: Final = (
opened
if decision == "deny"
else await _narrowed_to_selection(opened, selected_server_ids, lookup_server_reachability, master_key)
)
if isinstance(narrowed, Response):
return narrowed
flow_refusal: Final = _claim_refusal(
await _SingleUseGuard(cache).claim(
f"{_USED_FLOW_CACHE_PREFIX}{opened.jti}", CONNECT_FLOW_TTL_SECONDS + _CLAIM_TTL_BUFFER_SECONDS
@ -1238,13 +1266,62 @@ async def complete_connect_flow(
if flow_refusal is not None:
return flow_refusal
response: Final = (
_denied_flow_response(opened) if decision == "deny" else _approved_flow_response(opened, delivery, team_id, now)
_denied_flow_response(narrowed)
if decision == "deny"
else _approved_flow_response(narrowed, delivery, team_id, now)
)
path, secure = _cookie_path_and_secure(request)
response.delete_cookie(key=_flow_cookie_name(flow_handle), path=path, secure=secure, httponly=True, samesite="lax")
return response
async def _narrowed_to_selection(
flow: _ConnectFlow,
selected_server_ids: Sequence[str],
lookup_server_reachability: LookupServerReachability,
master_key: str | None,
) -> _ConnectFlow | Response:
if (
not any(server_id.strip() for server_id in selected_server_ids)
or flow.resource_server_ids is not None
or flow.audience is not None
or flow.unresolved_resource
):
return flow
from litellm.proxy.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # import cycle
global_mcp_server_manager,
)
listed_ids: Final = frozenset(server.server_id for server in global_mcp_server_manager.get_registry().values())
known_ids: Final = tuple(dict.fromkeys(server_id for server_id in selected_server_ids if server_id in listed_ids))
if not known_ids:
return _oauth_error(400, "invalid_request", "the requested MCP server is no longer available")
reachable_ids: Final = tuple(
[server_id for server_id in known_ids if await lookup_server_reachability(flow.user_id, server_id)]
)
if not reachable_ids:
return _oauth_error(400, "invalid_request", "you do not have access to the requested MCP server")
upstream_ids: Final = tuple(item.server_id for item in flow.upstream_scopes)
granted: Final = tuple(dict.fromkeys((*upstream_ids, *reachable_ids)))
if not _session_tokens_fit(flow, granted, master_key):
return _oauth_error(
400, "invalid_request", "too many MCP servers are turned on for one connection, turn some off and try again"
)
return flow.model_copy(update={"resource_server_ids": granted})
def _session_tokens_fit(flow: _ConnectFlow, server_ids: tuple[str, ...], master_key: str | None) -> bool:
"""A grant that cannot be minted would fail at /token after the code is spent."""
keys: Final = None if master_key is None else active_session_signing_keys(master_key)
if not isinstance(keys, SessionSigningKeys):
return True
principal: Final = SessionPrincipal(user_id=flow.user_id, client_id=flow.client_id, resource_server_ids=server_ids)
now: Final = datetime.now(timezone.utc)
access: Final = mint_session_token(principal, keys, now)
rotated_refresh: Final = mint_session_refresh_token(principal, keys, now, family=secrets.token_urlsafe(16))
return isinstance(access, MintedSessionToken) and isinstance(rotated_refresh, MintedSessionToken)
def _state_param(state: str) -> tuple[tuple[str, str], ...]:
return (("state", state),) if state else ()

View file

@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { ConnectFlowStatus } from "@/components/networking";
import ConnectFlowBanner, { isLoopbackOrigin } from "./ConnectFlowBanner";
import ConnectFlowBanner, { isLoopbackOrigin, type FinishSelection } from "./ConnectFlowBanner";
const { startOAuthFlow } = vi.hoisted(() => ({ startOAuthFlow: vi.fn() }));
@ -27,7 +27,7 @@ const unscoped = (client_origin: string): ConnectFlowStatus => ({
servers: null,
});
const renderBanner = (clientOrigin: string) =>
const renderBanner = (clientOrigin: string, finish?: FinishSelection) =>
render(
<ConnectFlowBanner
flowHandle="flow-handle-123"
@ -35,6 +35,7 @@ const renderBanner = (clientOrigin: string) =>
accessToken="tok"
onConnected={vi.fn()}
failed={false}
finish={finish}
/>,
);
@ -51,6 +52,31 @@ describe("ConnectFlowBanner", () => {
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
});
it("posts the id of each server that is on with Finish", () => {
renderBanner("https://claude.ai", { status: "ready", serverIds: ["catalog-id", "orders-id"] });
expect(screen.getByDisplayValue("catalog-id")).toHaveAttribute("name", "server");
expect(screen.getByDisplayValue("orders-id")).toHaveAttribute("name", "server");
expect(screen.getByRole("button", { name: /finish connecting/i })).toBeEnabled();
});
it("holds Finish until the page has loaded its servers", () => {
renderBanner("https://claude.ai", { status: "waiting" });
expect(screen.getByRole("button", { name: /finish connecting/i })).toBeDisabled();
expect(screen.queryByText("Turn on at least one server to finish connecting.")).not.toBeInTheDocument();
});
it("holds Finish and says why when every server is off", () => {
renderBanner("https://claude.ai", { status: "nothing-on" });
expect(screen.getByRole("button", { name: /finish connecting/i })).toBeDisabled();
expect(screen.getByRole("button", { name: /finish connecting/i })).toHaveAccessibleDescription(
"Turn on at least one server to finish connecting.",
);
expect(screen.queryByDisplayValue(/./, { exact: false })).toHaveAttribute("name", "flow");
});
it("lists multi-server consent and requires every server before finishing", () => {
const flow: ConnectFlowStatus = {
state: "multi",

View file

@ -6,6 +6,7 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { getProxyBaseUrl, ConnectFlowStatus } from "@/components/networking";
import { OAuth2ConnectButton } from "@/components/chat/MCPAppsPanel";
import { Button } from "@/components/ui/button";
import i18n from "@/i18n/i18n";
interface Props {
@ -14,8 +15,16 @@ interface Props {
accessToken: string;
onConnected: () => void;
failed: boolean;
finish?: FinishSelection;
}
export type FinishSelection =
| { status: "waiting" }
| { status: "nothing-on" }
| { status: "ready"; serverIds: readonly string[] };
const FINISH_WITHOUT_SERVERS: FinishSelection = { status: "ready", serverIds: [] };
/** Finish remains an explicit POST because a cross-site navigation must never mint a code. */
export function isLoopbackOrigin(origin: string | null): boolean {
if (!origin) return false;
@ -44,7 +53,9 @@ const copyFor = (
if (flow.state === "unscoped") {
return [
t("chat.connectFlowBanner.unscoped.title", { client: clientLabel }),
t("chat.connectFlowBanner.unscoped.description", { client: clientLabel }),
flow.selectable === false
? t("chat.connectFlowBanner.unscoped.fixed", { client: clientLabel })
: t("chat.connectFlowBanner.unscoped.description", { client: clientLabel }),
];
}
if (flow.state === "multi") {
@ -89,7 +100,14 @@ const vendorServersFor = (
return [{ server_id: flow.server_id, server_name: flow.server_name }];
};
const ConnectFlowBanner: React.FC<Props> = ({ flowHandle, flow, accessToken, onConnected, failed }) => {
const ConnectFlowBanner: React.FC<Props> = ({
flowHandle,
flow,
accessToken,
onConnected,
failed,
finish = FINISH_WITHOUT_SERVERS,
}) => {
const { t } = useTranslation();
const action = `${getProxyBaseUrl()}/authorize/complete`;
const state = failed || flow === undefined ? "stale" : flow.state;
@ -130,13 +148,17 @@ const ConnectFlowBanner: React.FC<Props> = ({ flowHandle, flow, accessToken, onC
))}
<form method="POST" action={action}>
<input type="hidden" name="flow" value={flowHandle} />
{finish.status === "ready" &&
finish.serverIds.map((serverId) => <input key={serverId} type="hidden" name="server" value={serverId} />)}
{canFinish && (
<button
<Button
type="submit"
className="h-[38px] rounded-md bg-primary px-4 text-sm font-semibold text-primary-foreground hover:bg-primary/90"
disabled={finish.status !== "ready"}
aria-describedby={finish.status === "nothing-on" ? "connect-flow-nothing-on" : undefined}
className="h-[38px] px-4 font-semibold"
>
{t("chat.connectFlowBanner.finish")}
</button>
</Button>
)}
{canCancel && (
<button
@ -154,6 +176,11 @@ const ConnectFlowBanner: React.FC<Props> = ({ flowHandle, flow, accessToken, onC
{t("chat.connectFlowBanner.manualDelivery")}
</label>
)}
{finish.status === "nothing-on" && (
<p id="connect-flow-nothing-on" className="mt-2 text-[13px] text-muted-foreground">
{t("chat.connectFlowBanner.nothingOn")}
</p>
)}
</form>
</div>
</div>

View file

@ -0,0 +1,397 @@
import { webcrypto } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ConnectFlowSurface from "./ConnectFlowSurface";
import McpOAuthCallbackPage from "@/app/mcp/oauth/callback/page";
import {
exchangeMcpOAuthToken,
fetchConnectFlow,
fetchMCPServers,
getMCPOAuthUserCredentialStatus,
listMCPTools,
registerMcpOAuthClient,
storeMCPOAuthUserCredential,
type ConnectFlowStatus,
} from "@/components/networking";
import type { MCPServer } from "@/components/mcp_tools/types";
const query = vi.hoisted(() => ({ params: {} as Record<string, string | null> }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn() }),
useSearchParams: () => ({ get: (key: string) => query.params[key] ?? null }),
}));
vi.mock("@/components/networking", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/networking")>()),
fetchConnectFlow: vi.fn(),
fetchMCPServers: vi.fn(),
listMCPTools: vi.fn(),
getMCPOAuthUserCredentialStatus: vi.fn(),
registerMcpOAuthClient: vi.fn(),
exchangeMcpOAuthToken: vi.fn(),
storeMCPOAuthUserCredential: vi.fn(),
}));
const CONNECT_PAGE = "https://gateway.example.com/ui/connect?connect_flow=flow-handle-123";
const realLocation = Object.getOwnPropertyDescriptor(window, "location");
const browser = { visits: [CONNECT_PAGE] };
const stubBrowserLocation = () => {
browser.visits = [CONNECT_PAGE];
const location = {
origin: "https://gateway.example.com",
pathname: "/ui/connect",
get href() {
return browser.visits[browser.visits.length - 1];
},
set href(url: string) {
browser.visits.push(url);
},
replace: (url: string) => {
browser.visits.push(url);
},
};
Object.defineProperty(window, "location", { configurable: true, value: location });
};
const vendorVisits = () => browser.visits.filter((url) => url.includes("/v1/mcp/server/oauth/"));
const servers = [
{ server_id: "catalog-id", server_name: "catalog", auth_type: "none", connected_app_reachable: true },
{ server_id: "orders-id", server_name: "orders", auth_type: "none", connected_app_reachable: true },
{ server_id: "tracker-id", server_name: "tracker", auth_type: "none", connected_app_reachable: true },
] as MCPServer[];
const vault = {
server_id: "vault-id",
server_name: "vault",
auth_type: "oauth2",
oauth2_flow: "authorization_code",
connected_app_reachable: true,
} as MCPServer;
const flow = (kind: ConnectFlowStatus["state"], connected: boolean | null = null): ConnectFlowStatus => ({
state: kind,
client_origin: "https://claude.ai",
server_id: kind === "interactive" || kind === "m2m" ? "s-design" : null,
server_name: kind === "interactive" || kind === "m2m" ? "design_tool" : null,
connected,
servers: null,
});
const deferred = <T,>() => {
let resolve: (value: T) => void = () => {};
let reject: (reason: Error) => void = () => {};
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve: (value: T) => resolve(value), reject: (reason: Error) => reject(reason) };
};
const renderSurface = () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const tree = (
<QueryClientProvider client={client}>
<ConnectFlowSurface accessToken="token-123" selectedServers={[]} onChange={vi.fn()} />
</QueryClientProvider>
);
const view = render(tree);
return { unmount: view.unmount, flushPendingRenders: () => view.rerender(tree) };
};
const openConnectPage = (result: ConnectFlowStatus = flow("unscoped")) => {
vi.mocked(fetchConnectFlow).mockResolvedValue(result);
return renderSurface();
};
const returnFromVendor = async (page: { unmount: () => void }) => {
await waitFor(() => expect(window.location.href).toContain("/authorize"));
const vendorState = new URL(window.location.href).searchParams.get("state");
page.unmount();
query.params = { code: "vendor-code", state: vendorState };
const callback = render(<McpOAuthCallbackPage />);
await waitFor(() => expect(window.location.href).toContain("mcpOauthReturn=apps"));
callback.unmount();
query.params = { connect_flow: "flow-handle-123", mcpOauthReturn: "apps" };
return renderSurface();
};
const serverSwitch = (name: string) => screen.findByRole("switch", { name: `Include ${name} in this connection` });
const finishButton = () => screen.getByRole("button", { name: /finish connecting/i });
const serverIdsFinishSends = () =>
screen
.queryAllByDisplayValue(/./)
.filter((field) => field.getAttribute("name") === "server")
.map((field) => field.getAttribute("value"));
beforeEach(() => {
query.params = { connect_flow: "flow-handle-123" };
vi.stubGlobal("crypto", webcrypto);
stubBrowserLocation();
vi.mocked(fetchMCPServers).mockResolvedValue(servers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
vi.mocked(registerMcpOAuthClient).mockResolvedValue({ client_id: "registered-client" });
vi.mocked(exchangeMcpOAuthToken).mockResolvedValue({ access_token: "vendor-token" });
vi.mocked(getMCPOAuthUserCredentialStatus).mockImplementation(async (_token, serverId) => ({
server_id: serverId,
has_credential: false,
is_expired: false,
}));
vi.mocked(storeMCPOAuthUserCredential).mockImplementation(async (_token, serverId) => ({
server_id: serverId,
has_credential: true,
is_expired: false,
}));
});
afterEach(() => {
query.params = {};
sessionStorage.clear();
vi.unstubAllGlobals();
if (realLocation) Object.defineProperty(window, "location", realLocation);
vi.resetAllMocks();
});
describe("the connect page as one tree", () => {
it.each([
{ result: flow("unscoped"), grid: true, finish: true, cancel: false },
{ result: flow("interactive", true), grid: false, finish: true, cancel: true },
{ result: flow("m2m", true), grid: false, finish: true, cancel: true },
{ result: flow("stale"), grid: false, finish: false, cancel: true },
])("renders $result.state without widening its action surface", async ({ result, grid, finish, cancel }) => {
openConnectPage(result);
await screen.findAllByText(/https:\/\/claude\.ai/);
expect(screen.queryByRole("tablist") !== null).toBe(grid);
expect(screen.queryByRole("button", { name: /finish connecting/i }) !== null).toBe(finish);
expect(screen.queryByRole("button", { name: "Cancel" }) !== null).toBe(cancel);
expect(vendorVisits()).toEqual([]);
});
it.each([flow("interactive", true), flow("m2m", true)])(
"leaves Finish open on a $state flow, which has no panel to wait for",
async (result) => {
openConnectPage(result);
expect(await screen.findByRole("button", { name: /finish connecting/i })).toBeEnabled();
},
);
it("keeps the grid and Finish hidden until the gateway accepts a handle", () => {
vi.mocked(fetchConnectFlow).mockReturnValue(new Promise(() => {}));
renderSurface();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /finish connecting/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toHaveAttribute("value", "deny");
});
it("keeps the grid and Finish hidden when flow validation fails", async () => {
query.params = { connect_flow: "invalid-handle" };
vi.mocked(fetchConnectFlow).mockRejectedValue(new Error("invalid flow"));
renderSurface();
await screen.findByRole("button", { name: "Cancel" });
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /finish connecting/i })).not.toBeInTheDocument();
});
it("sends the user to the vendor once on an interactive flow that is not signed in", async () => {
openConnectPage(flow("interactive", false));
await waitFor(() => expect(window.location.href).toContain("/v1/mcp/server/oauth/s-design/authorize"));
expect(vendorVisits()).toHaveLength(1);
expect(screen.queryByRole("button", { name: /finish connecting/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
});
it("shows Finish once a login returning from the vendor is saved", async () => {
let saved = false;
vi.mocked(fetchConnectFlow).mockImplementation(async () => flow("interactive", saved));
vi.mocked(storeMCPOAuthUserCredential).mockImplementation(async (_token, serverId) => {
saved = true;
return { server_id: serverId, has_credential: true, is_expired: false };
});
await returnFromVendor(renderSurface());
expect(await screen.findByRole("button", { name: /finish connecting/i })).toBeEnabled();
expect(exchangeMcpOAuthToken).toHaveBeenCalledWith(
expect.objectContaining({ serverId: "s-design", code: "vendor-code" }),
);
expect(storeMCPOAuthUserCredential).toHaveBeenCalledWith(
"token-123",
"s-design",
expect.objectContaining({ access_token: "vendor-token" }),
);
});
it("offers Connect again, without Finish, when a login returning from the vendor cannot be saved", async () => {
vi.mocked(fetchConnectFlow).mockResolvedValue(flow("interactive", false));
const save = deferred<never>();
vi.mocked(storeMCPOAuthUserCredential).mockReturnValue(save.promise);
await returnFromVendor(renderSurface());
await screen.findByRole("button", { name: "Connecting…" });
await act(async () => {
save.reject(new Error("save failed"));
});
expect(await screen.findByRole("button", { name: "Connect" })).toBeEnabled();
expect(screen.queryByRole("button", { name: /finish connecting/i })).not.toBeInTheDocument();
});
it("holds Finish while a login returning from the vendor is being saved", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue([...servers, vault]);
const exchange = deferred<{ access_token: string }>();
vi.mocked(exchangeMcpOAuthToken).mockReturnValue(exchange.promise);
const page = openConnectPage();
fireEvent.click(await screen.findByText("Connect", { exact: true }));
await returnFromVendor(page);
expect(await screen.findByRole("button", { name: /finish connecting/i })).toBeDisabled();
expect(serverIdsFinishSends()).toEqual([]);
await act(async () => {
exchange.resolve({ access_token: "vendor-token" });
});
await waitFor(() => expect(finishButton()).toBeEnabled());
expect(storeMCPOAuthUserCredential).toHaveBeenCalledWith(
"token-123",
"vault-id",
expect.objectContaining({ access_token: "vendor-token" }),
);
expect(serverIdsFinishSends()).toEqual([]);
});
it("sends only the flow while every switch is on, so the session stays as on main", async () => {
openConnectPage();
expect(await serverSwitch("catalog")).toBeChecked();
expect(await serverSwitch("orders")).toBeChecked();
expect(await serverSwitch("tracker")).toBeChecked();
expect(finishButton()).toBeEnabled();
expect(screen.getByDisplayValue("flow-handle-123")).toHaveAttribute("name", "flow");
expect(serverIdsFinishSends()).toEqual([]);
});
it("sends the servers still on once one is switched off, and only the flow once it is back on", async () => {
openConnectPage();
const orders = await serverSwitch("orders");
fireEvent.click(orders);
expect(orders).not.toBeChecked();
expect(serverIdsFinishSends()).toEqual(["catalog-id", "tracker-id"]);
expect(finishButton()).toBeEnabled();
fireEvent.click(orders);
expect(serverIdsFinishSends()).toEqual([]);
});
it("holds Finish and says why once every switch is off", async () => {
openConnectPage();
fireEvent.click(await serverSwitch("catalog"));
fireEvent.click(await serverSwitch("orders"));
fireEvent.click(await serverSwitch("tracker"));
expect(finishButton()).toBeDisabled();
expect(finishButton()).toHaveAccessibleDescription("Turn on at least one server to finish connecting.");
expect(serverIdsFinishSends()).toEqual([]);
});
it("keeps a server the client requires on, locked and sent", async () => {
openConnectPage({ ...flow("unscoped"), required_server_ids: ["tracker-id"] });
const tracker = await serverSwitch("tracker");
fireEvent.click(tracker);
fireEvent.click(await serverSwitch("orders"));
expect(tracker).toBeChecked();
expect(screen.getByText("Required by this client")).toBeInTheDocument();
expect(serverIdsFinishSends()).toEqual(["catalog-id", "tracker-id"]);
});
it("keeps every switch on and sends only the flow when the flow cannot be narrowed", async () => {
openConnectPage({ ...flow("unscoped"), selectable: false });
const orders = await serverSwitch("orders");
fireEvent.click(orders);
expect(orders).toBeChecked();
expect(screen.getByText(/https:\/\/claude\.ai will get every server you can reach/)).toBeInTheDocument();
expect(finishButton()).toBeEnabled();
expect(serverIdsFinishSends()).toEqual([]);
});
it("keeps a switch off across a reload and holds Finish until the server list is back", async () => {
const { unmount } = openConnectPage();
fireEvent.click(await serverSwitch("orders"));
unmount();
const list = deferred<MCPServer[]>();
vi.mocked(fetchMCPServers).mockReturnValue(list.promise);
openConnectPage();
expect(await screen.findByRole("button", { name: /finish connecting/i })).toBeDisabled();
expect(serverIdsFinishSends()).toEqual([]);
await act(async () => {
list.resolve(servers);
});
expect(await serverSwitch("orders")).not.toBeChecked();
await waitFor(() => expect(serverIdsFinishSends()).toEqual(["catalog-id", "tracker-id"]));
});
it("holds Finish after a reload with a switch off when the server list fails to load", async () => {
const { unmount } = openConnectPage();
fireEvent.click(await serverSwitch("orders"));
unmount();
vi.mocked(fetchMCPServers).mockRejectedValue(new Error("list failed"));
const page = openConnectPage();
expect(await screen.findByText(/Could not load your MCP servers/)).toBeInTheDocument();
page.flushPendingRenders();
expect(finishButton()).toBeDisabled();
expect(serverIdsFinishSends()).toEqual([]);
});
it("lets the user finish as on main while the server list is still loading", async () => {
vi.mocked(fetchMCPServers).mockReturnValue(new Promise(() => {}));
openConnectPage();
expect(await screen.findByRole("button", { name: /finish connecting/i })).toBeEnabled();
expect(serverIdsFinishSends()).toEqual([]);
});
it.each([
{
when: "fails to load",
list: () => Promise.reject<MCPServer[]>(new Error("list failed")),
shown: /Could not load your MCP servers/,
},
{
when: "comes back empty",
list: () => Promise.resolve<MCPServer[]>([]),
shown: /No MCP servers are available to this connection yet/,
},
])("lets the user finish as on main when the server list $when", async ({ list, shown }) => {
vi.mocked(fetchMCPServers).mockImplementation(list);
const page = openConnectPage();
expect(await screen.findByText(shown)).toBeInTheDocument();
page.flushPendingRenders();
expect(finishButton()).toBeEnabled();
expect(serverIdsFinishSends()).toEqual([]);
});
});

View file

@ -2,11 +2,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ConnectFlowSurface from "./ConnectFlowSurface";
import { fetchConnectFlow } from "@/components/networking";
import type { FinishSelection } from "./ConnectFlowBanner";
import type { LockedOn, ServerListReport } from "./MCPAppsPanel";
import { fetchConnectFlow, type ConnectFlowStatus } from "@/components/networking";
import { pendingUserMcpOAuthServerId } from "@/hooks/useUserMcpOAuthFlow";
const { startOAuthFlow, state, onSuccess } = vi.hoisted(() => ({
startOAuthFlow: vi.fn(),
onSuccess: { current: undefined as (() => void) | undefined },
const { state, panel, banner } = vi.hoisted(() => ({
panel: {
offServerIds: undefined as readonly string[] | undefined,
lockedOn: undefined as LockedOn,
onOffServerIdsChange: undefined as ((serverIds: string[]) => void) | undefined,
onConnectedServers: undefined as ((servers: ServerListReport) => void) | undefined,
},
banner: {
flow: undefined as ConnectFlowStatus | undefined,
failed: undefined as boolean | undefined,
finish: undefined as FinishSelection | undefined,
},
state: { oauthReturn: null as string | null, connectFlow: null as string | null },
}));
@ -16,110 +28,190 @@ vi.mock("next/navigation", () => ({
get: (key: string) => ({ mcpOauthReturn: state.oauthReturn, connect_flow: state.connectFlow })[key] ?? null,
}),
}));
vi.mock("@/components/networking", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/networking")>()),
fetchConnectFlow: vi.fn(),
getProxyBaseUrl: () => "https://gateway.example.com",
vi.mock("@/components/networking", () => ({ fetchConnectFlow: vi.fn() }));
vi.mock("@/hooks/useUserMcpOAuthFlow", () => ({ pendingUserMcpOAuthServerId: vi.fn(() => null) }));
vi.mock("@/components/chat/MCPAppsPanel", () => ({
default: ({ offServerIds, lockedOn, onOffServerIdsChange, onConnectedServers }: typeof panel) => {
panel.offServerIds = offServerIds;
panel.lockedOn = lockedOn;
panel.onOffServerIdsChange = onOffServerIdsChange;
panel.onConnectedServers = onConnectedServers;
return <div data-testid="mcp-apps-panel" />;
},
}));
vi.mock("@/components/chat/MCPAppsPanel", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/chat/MCPAppsPanel")>()),
default: () => <div data-testid="mcp-apps-panel" />,
}));
vi.mock("@/hooks/useUserMcpOAuthFlow", () => ({
useUserMcpOAuthFlow: ({ onSuccess: success }: { onSuccess: () => void }) => {
onSuccess.current = success;
return { startOAuthFlow, status: "idle" };
vi.mock("@/components/chat/ConnectFlowBanner", () => ({
default: ({ flow, failed, finish }: typeof banner) => {
banner.flow = flow;
banner.failed = failed;
banner.finish = finish;
return <div data-testid="connect-flow-banner" />;
},
}));
const flow = (state: "unscoped" | "interactive" | "m2m" | "multi" | "stale", connected: boolean | null = null) => ({
const flow = (state: ConnectFlowStatus["state"], connected: boolean | null = null): ConnectFlowStatus => ({
state,
client_origin: "https://claude.ai",
server_id: state === "interactive" || state === "m2m" ? "s-design" : null,
server_name: state === "interactive" || state === "m2m" ? "design_tool" : null,
connected,
servers:
state === "multi"
? [
{ server_id: "s-alpha", server_name: "alpha", connected: false },
{ server_id: "s-beta", server_name: "beta", connected: false },
]
: null,
servers: null,
});
const renderSurface = () =>
const everyServer: FinishSelection = { status: "ready", serverIds: [] };
const waiting: FinishSelection = { status: "waiting" };
const finishHanded = () => banner.finish ?? everyServer;
const renderSurface = (onChange = vi.fn()) =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<ConnectFlowSurface accessToken="token-123" selectedServers={[]} onChange={vi.fn()} />
<ConnectFlowSurface accessToken="token-123" selectedServers={[]} onChange={onChange} />
</QueryClientProvider>,
);
const openConnectPage = async (result: ConnectFlowStatus = flow("unscoped"), handle = "flow-handle-123") => {
state.connectFlow = handle;
vi.mocked(fetchConnectFlow).mockResolvedValue(result);
const page = renderSurface();
await screen.findByTestId("mcp-apps-panel");
return page;
};
afterEach(() => {
state.oauthReturn = null;
state.connectFlow = null;
onSuccess.current = undefined;
banner.flow = undefined;
banner.failed = undefined;
banner.finish = undefined;
sessionStorage.clear();
vi.clearAllMocks();
});
describe("ConnectFlowSurface", () => {
it.each([
{ result: flow("unscoped"), grid: true, finish: true, cancel: false, oauthStarts: 0 },
{ result: flow("interactive", false), grid: false, finish: false, cancel: true, oauthStarts: 1 },
{ result: flow("interactive", true), grid: false, finish: true, cancel: true, oauthStarts: 0 },
{ result: flow("m2m", true), grid: false, finish: true, cancel: true, oauthStarts: 0 },
{ result: flow("stale"), grid: false, finish: false, cancel: true, oauthStarts: 0 },
])(
"renders $result.state without widening its action surface",
async ({ result, grid, finish, cancel, oauthStarts }) => {
state.connectFlow = "flow-handle-123";
vi.mocked(fetchConnectFlow).mockResolvedValue(result);
renderSurface();
{ result: flow("unscoped"), grid: true },
{ result: flow("interactive", false), grid: false },
{ result: flow("interactive", true), grid: false },
{ result: flow("m2m", true), grid: false },
{ result: flow("stale"), grid: false },
])("hands the banner a $result.state flow and shows the grid only when it is unscoped", async ({ result, grid }) => {
state.connectFlow = "flow-handle-123";
vi.mocked(fetchConnectFlow).mockResolvedValue(result);
renderSurface();
await screen.findByRole("button", { name: /finish connecting|cancel|connect/i });
await waitFor(() => expect(startOAuthFlow).toHaveBeenCalledTimes(oauthStarts));
expect(screen.queryByTestId("mcp-apps-panel") !== null).toBe(grid);
expect(screen.queryByRole("button", { name: /finish connecting/i }) !== null).toBe(finish);
expect(screen.queryByRole("button", { name: "Cancel" }) !== null).toBe(cancel);
},
);
await waitFor(() => expect(banner.flow).toEqual(result));
expect(screen.queryByTestId("mcp-apps-panel") !== null).toBe(grid);
});
it("keeps the grid and Finish hidden until the gateway accepts a handle", () => {
it("keeps the grid hidden until the gateway accepts a handle", () => {
state.connectFlow = "flow-handle-123";
vi.mocked(fetchConnectFlow).mockReturnValue(new Promise(() => {}));
renderSurface();
expect(screen.queryByTestId("mcp-apps-panel")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /finish connecting/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toHaveAttribute("value", "deny");
expect(banner.flow).toBeUndefined();
expect(banner.failed).toBe(false);
});
it("keeps the grid and Finish hidden when flow validation fails", async () => {
it("keeps the grid hidden and tells the banner when flow validation fails", async () => {
state.connectFlow = "invalid-handle";
vi.mocked(fetchConnectFlow).mockRejectedValue(new Error("invalid flow"));
renderSurface();
await screen.findByRole("button", { name: "Cancel" });
await waitFor(() => expect(banner.failed).toBe(true));
expect(screen.queryByTestId("mcp-apps-panel")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /finish connecting/i })).not.toBeInTheDocument();
});
it("refetches the sealed flow after the vendor connection completes", async () => {
state.connectFlow = "flow-handle-123";
vi.mocked(fetchConnectFlow)
.mockResolvedValueOnce(flow("interactive", false))
.mockResolvedValueOnce(flow("interactive", true));
renderSurface();
await waitFor(() => expect(startOAuthFlow).toHaveBeenCalledOnce());
await act(async () => onSuccess.current?.());
await screen.findByRole("button", { name: /finish connecting/i });
});
it("renders the ordinary panel without a flow handle", () => {
renderSurface();
expect(fetchConnectFlow).not.toHaveBeenCalled();
expect(screen.getByTestId("mcp-apps-panel")).toBeInTheDocument();
expect(screen.queryByTestId("connect-flow-banner")).not.toBeInTheDocument();
});
it("keeps the servers turned off across the OAuth round trip", async () => {
const { unmount } = await openConnectPage();
act(() => panel.onOffServerIdsChange?.(["orders-id"]));
unmount();
await openConnectPage();
expect(panel.offServerIds).toEqual(["orders-id"]);
});
it("starts a new connect flow with every server on", async () => {
const { unmount } = await openConnectPage();
act(() => panel.onOffServerIdsChange?.(["orders-id"]));
unmount();
await openConnectPage(flow("unscoped"), "flow-handle-456");
expect(panel.offServerIds).toEqual([]);
expect(panel.lockedOn).toBeUndefined();
});
it("locks every switch and never narrows a flow that cannot be narrowed", async () => {
await openConnectPage({ ...flow("unscoped"), selectable: false });
expect(panel.lockedOn).toBe("all");
act(() => panel.onConnectedServers?.({ on: ["catalog-id"], all: ["catalog-id", "orders-id"] }));
expect(finishHanded()).toEqual(everyServer);
});
it("locks only the servers the client requires and still narrows to the servers that are on", async () => {
await openConnectPage({ ...flow("unscoped"), required_server_ids: ["tracker-id"] });
expect(panel.lockedOn).toEqual(["tracker-id"]);
act(() => panel.onConnectedServers?.({ on: ["tracker-id"], all: ["tracker-id", "orders-id"] }));
expect(banner.finish).toEqual({ status: "ready", serverIds: ["tracker-id"] });
});
it("holds Finish on return from a vendor sign-in before the panel has said anything", async () => {
vi.mocked(pendingUserMcpOAuthServerId).mockReturnValueOnce("vault-id");
await openConnectPage();
expect(banner.finish).toEqual(waiting);
});
it.each<{ when: string; off: string[]; report: ServerListReport; finish: FinishSelection }>([
{ when: "the list is loading and nothing is off", off: [], report: "loading", finish: everyServer },
{
when: "the list is loading after a server was turned off",
off: ["orders-id"],
report: "loading",
finish: waiting,
},
{ when: "the list failed to load and nothing is off", off: [], report: "failed", finish: everyServer },
{ when: "the list failed after a server was turned off", off: ["orders-id"], report: "failed", finish: waiting },
{ when: "a login returning from OAuth is being saved", off: [], report: "saving-login", finish: waiting },
{
when: "every server is on",
off: [],
report: { on: ["catalog-id", "orders-id"], all: ["catalog-id", "orders-id"] },
finish: everyServer,
},
{
when: "one server is off",
off: ["orders-id"],
report: { on: ["catalog-id"], all: ["catalog-id", "orders-id"] },
finish: { status: "ready", serverIds: ["catalog-id"] },
},
{
when: "every server is off",
off: ["catalog-id", "orders-id"],
report: { on: [], all: ["catalog-id", "orders-id"] },
finish: { status: "nothing-on" },
},
{ when: "no server is reachable", off: [], report: { on: [], all: [] }, finish: everyServer },
])("tells the banner Finish is $finish.status when $when", async ({ off, report, finish }) => {
await openConnectPage();
act(() => panel.onOffServerIdsChange?.(off));
act(() => panel.onConnectedServers?.(report));
expect(banner.finish).toEqual(finish);
});
});

View file

@ -1,11 +1,12 @@
"use client";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import MCPAppsPanel from "@/components/chat/MCPAppsPanel";
import ConnectFlowBanner from "@/components/chat/ConnectFlowBanner";
import MCPAppsPanel, { type ServerListReport } from "@/components/chat/MCPAppsPanel";
import ConnectFlowBanner, { type FinishSelection } from "@/components/chat/ConnectFlowBanner";
import { fetchConnectFlow } from "@/components/networking";
import { pendingUserMcpOAuthServerId } from "@/hooks/useUserMcpOAuthFlow";
interface Props {
accessToken: string;
@ -13,6 +14,33 @@ interface Props {
onChange: (servers: string[]) => void;
}
const offServersKey = (flow: string) => `litellm-connect-off:${flow}`;
const storedServerIds = (key: string): string[] => {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(key) ?? "null");
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
} catch {
return [];
}
};
const rememberServerIds = (key: string, serverIds: string[]) => {
try {
sessionStorage.setItem(key, JSON.stringify(serverIds));
} catch {}
};
const finishSelectionFor = (connected: ServerListReport, offServerIds: readonly string[]): FinishSelection => {
if (connected === "saving-login") return { status: "waiting" };
if (typeof connected === "string") {
return offServerIds.length === 0 ? { status: "ready", serverIds: [] } : { status: "waiting" };
}
if (connected.on.length === connected.all.length) return { status: "ready", serverIds: [] };
if (connected.on.length === 0) return { status: "nothing-on" };
return { status: "ready", serverIds: connected.on };
};
/** Renders the sealed gateway connect flow without trusting URL context. */
const ConnectFlowSurface: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const router = useRouter();
@ -35,11 +63,24 @@ const ConnectFlowSurface: React.FC<Props> = ({ accessToken, selectedServers, onC
retry: false,
};
const { data: flow, isError, refetch } = useQuery(flowQuery);
const [connected, setConnected] = useState<ServerListReport>(() =>
pendingUserMcpOAuthServerId() === null ? "loading" : "saving-login",
);
const [offServerIds, setOffServerIds] = useState(() =>
connectFlow ? storedServerIds(offServersKey(connectFlow)) : [],
);
const changeOffServerIds = (serverIds: string[]) => {
if (connectFlow) rememberServerIds(offServersKey(connectFlow), serverIds);
setOffServerIds(serverIds);
};
if (connectFlow === null) {
return <MCPAppsPanel accessToken={accessToken} selectedServers={selectedServers} onChange={onChange} />;
}
const narrowable = flow?.state === "unscoped" && flow.selectable !== false;
return (
<>
<ConnectFlowBanner
@ -48,9 +89,19 @@ const ConnectFlowSurface: React.FC<Props> = ({ accessToken, selectedServers, onC
accessToken={accessToken}
onConnected={refetch}
failed={isError}
finish={narrowable ? finishSelectionFor(connected, offServerIds) : undefined}
/>
{flow?.state === "unscoped" && (
<MCPAppsPanel accessToken={accessToken} selectedServers={selectedServers} onChange={onChange} connectMode />
<MCPAppsPanel
accessToken={accessToken}
selectedServers={selectedServers}
onChange={onChange}
connectMode
offServerIds={offServerIds}
onOffServerIdsChange={changeOffServerIds}
lockedOn={narrowable ? flow.required_server_ids : "all"}
onConnectedServers={setConnected}
/>
)}
</>
);

View file

@ -0,0 +1,587 @@
import React from "react";
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, { type LockedOn, type ServerListReport } from "./MCPAppsPanel";
import {
deleteMCPOAuthUserCredential,
fetchMCPServers,
getMCPOAuthUserCredentialStatus,
listMCPTools,
} from "../networking";
import type { MCPOAuthUserCredentialStatus } from "../networking";
import type { MCPServer } from "../mcp_tools/types";
import { setServerRootPath } from "@/lib/serverRootPath";
vi.mock("../networking", () => ({
fetchMCPServers: vi.fn(),
getMCPOAuthUserCredentialStatus: vi.fn(),
listMCPTools: vi.fn(),
deleteMCPOAuthUserCredential: vi.fn(),
}));
const oauthReturn = vi.hoisted(() => ({ serverId: null as string | null, status: "idle", onSuccess: () => {} }));
vi.mock("@/hooks/useUserMcpOAuthFlow", () => ({
pendingUserMcpOAuthServerId: () => oauthReturn.serverId,
useUserMcpOAuthFlow: ({ serverId, onSuccess }: { serverId: string; onSuccess: () => void }) => {
if (serverId !== oauthReturn.serverId) return { startOAuthFlow: vi.fn(), status: "idle" };
oauthReturn.onSuccess = onSuccess;
return { startOAuthFlow: vi.fn(), status: oauthReturn.status };
},
}));
const servers = [
{
server_id: "s-ext",
server_name: "external_logo",
auth_type: "none",
mcp_info: { server_name: "external_logo", logo_url: "https://cdn.example.com/ext.png" },
},
{
server_id: "s-local",
server_name: "local_logo",
auth_type: "none",
mcp_info: { server_name: "local_logo", logo_url: "/ui/assets/logos/github.svg" },
},
{
server_id: "s-none",
server_name: "no_logo",
auth_type: "none",
},
] as MCPServer[];
const renderPanel = () =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MCPAppsPanel accessToken="tok" selectedServers={[]} onChange={vi.fn()} />
</QueryClientProvider>,
);
describe("MCPAppsPanel logos", () => {
afterEach(() => {
setServerRootPath("/");
});
it("resolves backend logo_url values in the server grid", async () => {
setServerRootPath("/litellm");
vi.mocked(fetchMCPServers).mockResolvedValue(servers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderPanel();
expect(await screen.findByText("external_logo")).toBeInTheDocument();
expect(screen.getByAltText("external_logo logo")).toHaveAttribute("src", "https://cdn.example.com/ext.png");
expect(screen.getByAltText("local_logo logo")).toHaveAttribute("src", "/litellm/ui/assets/logos/github.svg");
});
it("renders a colored letter avatar for servers without logo_url", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(servers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderPanel();
expect(await screen.findByText("no_logo")).toBeInTheDocument();
expect(screen.queryByAltText("no_logo logo")).not.toBeInTheDocument();
expect(screen.getByText("N")).toBeInTheDocument();
});
it("resolves the logo_url in the detail header", async () => {
setServerRootPath("/litellm");
vi.mocked(fetchMCPServers).mockResolvedValue(servers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderPanel();
fireEvent.click(await screen.findByText("local_logo"));
expect(await screen.findByRole("heading", { name: "local_logo" })).toBeInTheDocument();
expect(screen.getByAltText("local_logo logo")).toHaveAttribute("src", "/litellm/ui/assets/logos/github.svg");
});
});
const connectServers = [
{
server_id: "s-m2m",
server_name: "service_tool",
auth_type: "oauth2",
oauth2_flow: "client_credentials",
connected_app_reachable: true,
},
{
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>,
);
const oauthServer = {
server_id: "s-oauth",
server_name: "oauth_srv",
auth_type: "oauth2",
oauth2_flow: "authorization_code",
connected_app_reachable: true,
} as MCPServer;
const SwitchPage = ({
onConnectedServers,
initialOff = [],
lockedOn,
}: {
onConnectedServers: (servers: ServerListReport) => void;
initialOff?: string[];
lockedOn?: LockedOn;
}) => {
const [off, setOff] = React.useState<string[]>(initialOff);
return (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MCPAppsPanel
accessToken="tok"
selectedServers={[]}
onChange={vi.fn()}
connectMode
offServerIds={off}
onOffServerIdsChange={setOff}
lockedOn={lockedOn}
onConnectedServers={onConnectedServers}
/>
</QueryClientProvider>
);
};
const lastReport = (onConnectedServers: ReturnType<typeof vi.fn>) => onConnectedServers.mock.lastCall?.[0];
describe("MCPAppsPanel switches in connect mode", () => {
beforeEach(() => {
vi.clearAllMocks();
oauthReturn.serverId = null;
oauthReturn.status = "idle";
oauthReturn.onSuccess = () => {};
});
it("reports every reachable server as on before any login is checked", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue([
oauthServer,
...connectServers,
{ server_id: "s-unsup", server_name: "unsupported_srv", auth_type: "oauth2_token_exchange" },
] as MCPServer[]);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
vi.mocked(getMCPOAuthUserCredentialStatus).mockReturnValue(new Promise(() => {}));
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} />);
await waitFor(() =>
expect(lastReport(onConnectedServers)).toEqual({
on: ["s-oauth", "s-m2m", "s-reach"],
all: ["s-oauth", "s-m2m", "s-reach"],
}),
);
expect(screen.getByRole("switch", { name: "Include oauth_srv in this connection" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Include service_tool in this connection" })).toBeChecked();
expect(
screen.queryByRole("switch", { name: "Include unsupported_srv in this connection" }),
).not.toBeInTheDocument();
});
it("leaves a server out once it is turned off, and back in once it is turned on", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} />);
const reachable = await screen.findByRole("switch", { name: "Include reachable_srv in this connection" });
fireEvent.click(reachable);
expect(lastReport(onConnectedServers)).toEqual({ on: ["s-m2m"], all: ["s-m2m", "s-reach"] });
expect(reachable).not.toBeChecked();
expect(screen.queryByRole("heading", { name: "reachable_srv" })).not.toBeInTheDocument();
fireEvent.click(reachable);
expect(lastReport(onConnectedServers)).toEqual({ on: ["s-m2m", "s-reach"], all: ["s-m2m", "s-reach"] });
});
it("lists only the servers that are on under the Connected tab", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
render(<SwitchPage onConnectedServers={vi.fn()} />);
fireEvent.click(await screen.findByRole("switch", { name: "Include reachable_srv in this connection" }));
fireEvent.click(screen.getByRole("tab", { name: "On (1)" }));
expect(await screen.findByText("service_tool")).toBeInTheDocument();
expect(screen.queryByText("reachable_srv")).not.toBeInTheDocument();
});
it("turns a server off from its detail view too", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} />);
fireEvent.click(await screen.findByText("service_tool"));
await screen.findByRole("heading", { name: "service_tool" });
fireEvent.click(screen.getByRole("switch", { name: "Include service_tool in this connection" }));
expect(lastReport(onConnectedServers)).toEqual({ on: ["s-reach"], all: ["s-m2m", "s-reach"] });
expect(screen.getByText("Off")).toBeInTheDocument();
});
it("keeps an OAuth server with no saved login on and offers a sign in", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue([oauthServer]);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
vi.mocked(getMCPOAuthUserCredentialStatus).mockResolvedValue({
server_id: "s-oauth",
has_credential: false,
is_expired: false,
} as MCPOAuthUserCredentialStatus);
render(<SwitchPage onConnectedServers={vi.fn()} />);
expect(await screen.findByText("Sign in to use")).toBeInTheDocument();
expect(screen.getByText("Connect", { exact: true })).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Include oauth_srv in this connection" })).toBeChecked();
});
it("keeps the saved login when an OAuth server is turned off", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue([oauthServer, ...connectServers]);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
vi.mocked(getMCPOAuthUserCredentialStatus).mockResolvedValue({
server_id: "s-oauth",
has_credential: true,
is_expired: false,
} as MCPOAuthUserCredentialStatus);
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} />);
await waitFor(() => expect(getMCPOAuthUserCredentialStatus).toHaveBeenCalled());
fireEvent.click(await screen.findByRole("switch", { name: "Include oauth_srv in this connection" }));
expect(lastReport(onConnectedServers)).toEqual({ on: ["s-m2m", "s-reach"], all: ["s-oauth", "s-m2m", "s-reach"] });
expect(deleteMCPOAuthUserCredential).not.toHaveBeenCalled();
expect(screen.queryByText("Sign in to use")).not.toBeInTheDocument();
});
it("keeps the switch where it was when a login returning from OAuth is saved", async () => {
oauthReturn.serverId = "s-oauth";
oauthReturn.status = "exchanging";
vi.mocked(fetchMCPServers).mockResolvedValue([oauthServer, ...connectServers]);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
vi.mocked(getMCPOAuthUserCredentialStatus).mockResolvedValue({
server_id: "s-oauth",
has_credential: false,
is_expired: false,
} as MCPOAuthUserCredentialStatus);
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} initialOff={["s-oauth"]} />);
await screen.findByText("oauth_srv");
expect(lastReport(onConnectedServers)).toBe("saving-login");
await act(async () => {
oauthReturn.status = "success";
oauthReturn.onSuccess();
});
expect(screen.getByRole("switch", { name: "Include oauth_srv in this connection" })).not.toBeChecked();
expect(lastReport(onConnectedServers)).toEqual({ on: ["s-m2m", "s-reach"], all: ["s-oauth", "s-m2m", "s-reach"] });
});
it("reports a returning login as being saved from the first report, before the hook starts", async () => {
oauthReturn.serverId = "s-oauth";
vi.mocked(fetchMCPServers).mockResolvedValue([oauthServer, ...connectServers]);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
vi.mocked(getMCPOAuthUserCredentialStatus).mockReturnValue(new Promise(() => {}));
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} />);
await screen.findByText("oauth_srv");
expect(new Set(onConnectedServers.mock.calls.map(([report]) => report))).toEqual(new Set(["saving-login"]));
});
it("shows every switch on and locked when the flow cannot be narrowed", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
render(<SwitchPage onConnectedServers={vi.fn()} initialOff={["s-reach"]} lockedOn="all" />);
const reachable = await screen.findByRole("switch", { name: "Include reachable_srv in this connection" });
expect(reachable).toBeChecked();
expect(reachable).toHaveAttribute("data-disabled");
expect(screen.queryByText("Required by this client")).not.toBeInTheDocument();
fireEvent.click(reachable);
expect(reachable).toBeChecked();
});
it("keeps a server the client requires on and locked, and leaves the others switchable", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} initialOff={["s-reach"]} lockedOn={["s-reach"]} />);
const required = await screen.findByRole("switch", { name: "Include reachable_srv in this connection" });
expect(required).toBeChecked();
expect(required).toHaveAttribute("data-disabled");
expect(screen.getByText("Required by this client")).toBeInTheDocument();
fireEvent.click(required);
fireEvent.click(screen.getByRole("switch", { name: "Include service_tool in this connection" }));
expect(required).toBeChecked();
expect(lastReport(onConnectedServers)).toEqual({ on: ["s-reach"], all: ["s-m2m", "s-reach"] });
});
it("ignores a stored off switch for a server that is no longer listed", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} initialOff={["s-deleted"]} />);
await waitFor(() =>
expect(lastReport(onConnectedServers)).toEqual({ on: ["s-m2m", "s-reach"], all: ["s-m2m", "s-reach"] }),
);
});
it("reports nothing while the list is still loading", async () => {
vi.mocked(fetchMCPServers).mockReturnValue(new Promise(() => {}));
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} />);
await waitFor(() => expect(fetchMCPServers).toHaveBeenCalled());
expect(onConnectedServers.mock.calls.map(([servers]) => servers)).toEqual(["loading"]);
});
it("reports nothing and says so when the list fails to load", async () => {
vi.mocked(fetchMCPServers).mockRejectedValue(new Error("list failed"));
const onConnectedServers = vi.fn();
render(<SwitchPage onConnectedServers={onConnectedServers} />);
expect(
await screen.findByText("Could not load your MCP servers. Reload the page to try again."),
).toBeInTheDocument();
expect(onConnectedServers.mock.calls.map(([servers]) => servers)).toEqual(["loading", "failed"]);
});
it("reports again once a reload succeeds after the list failed to load", async () => {
vi.mocked(fetchMCPServers).mockRejectedValueOnce(new Error("list failed")).mockResolvedValue(connectServers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const onConnectedServers = vi.fn();
const panel = (token: string) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MCPAppsPanel
accessToken={token}
selectedServers={[]}
onChange={vi.fn()}
connectMode
onConnectedServers={onConnectedServers}
/>
</QueryClientProvider>
);
const { rerender } = render(panel("tok"));
expect(await screen.findByText(/could not load your mcp servers/i)).toBeInTheDocument();
rerender(panel("tok-refreshed"));
await waitFor(() =>
expect(lastReport(onConnectedServers)).toEqual({ on: ["s-m2m", "s-reach"], all: ["s-m2m", "s-reach"] }),
);
});
it("leaves a login returning from OAuth to the server's own badge outside connect mode", async () => {
oauthReturn.serverId = "s-oauth";
oauthReturn.status = "exchanging";
vi.mocked(fetchMCPServers).mockResolvedValue([
{ server_id: "s-oauth", server_name: "oauth_srv", auth_type: "oauth2", oauth2_flow: "authorization_code" },
] as MCPServer[]);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
vi.mocked(getMCPOAuthUserCredentialStatus).mockResolvedValue({
server_id: "s-oauth",
has_credential: false,
is_expired: false,
} as MCPOAuthUserCredentialStatus);
renderConnectPanel(false);
expect(await screen.findByText("Connecting…")).toBeInTheDocument();
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
});
it("keeps the plain panel's empty text when the list fails to load outside connect mode", async () => {
vi.mocked(fetchMCPServers).mockRejectedValue(new Error("list failed"));
renderConnectPanel(false);
expect(await screen.findByText(/no mcp servers configured/i)).toBeInTheDocument();
});
});
describe("MCPAppsPanel connected-app reachability (LIT-4861)", () => {
beforeEach(() => {
vi.clearAllMocks();
oauthReturn.serverId = null;
oauthReturn.status = "idle";
});
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("On (2)")).toBeInTheDocument();
expect(screen.getByText("service_tool")).toBeInTheDocument();
expect(screen.queryByText("Connect", { exact: true })).not.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.queryByRole("switch")).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,
client,
onConnectedServers = vi.fn(),
}: {
token: string;
client: QueryClient;
onConnectedServers?: (servers: ServerListReport) => void;
}) => (
<QueryClientProvider client={client}>
<MCPAppsPanel
accessToken={token}
selectedServers={[]}
onChange={vi.fn()}
connectMode
onConnectedServers={onConnectedServers}
/>
</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" client={client} />);
fireEvent.click(await screen.findByText("revoked_srv"));
expect(await screen.findByRole("heading", { name: "revoked_srv" })).toBeInTheDocument();
rerender(<ConnectPanel token="tok-refreshed" client={client} />);
await waitFor(() => expect(screen.queryByRole("heading", { name: "revoked_srv" })).not.toBeInTheDocument());
expect(screen.queryByText("revoked_srv")).not.toBeInTheDocument();
expect(screen.getByText("reachable_srv")).toBeInTheDocument();
});
it("stops reporting a server once a refetch removes it from the reachable set", async () => {
vi.mocked(fetchMCPServers).mockResolvedValueOnce(revocable(true)).mockResolvedValueOnce(revocable(false));
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
const onConnectedServers = vi.fn();
const client = newClient();
const { rerender } = render(<ConnectPanel token="tok" client={client} onConnectedServers={onConnectedServers} />);
await waitFor(() =>
expect(lastReport(onConnectedServers)).toEqual({ on: ["s-reach", "s-drop"], all: ["s-reach", "s-drop"] }),
);
rerender(<ConnectPanel token="tok-refreshed" client={client} onConnectedServers={onConnectedServers} />);
await waitFor(() => expect(lastReport(onConnectedServers)).toEqual({ on: ["s-reach"], all: ["s-reach"] }));
});
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" client={client} />);
rerender(<ConnectPanel token="tok-refreshed" client={client} />);
expect(await screen.findByText("reachable_srv")).toBeInTheDocument();
await act(async () => {
finishStaleLoad(revocable(true));
});
expect(screen.queryByText("revoked_srv")).not.toBeInTheDocument();
});
});

View file

@ -1,302 +0,0 @@
import React from "react";
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";
import type { MCPServer } from "../mcp_tools/types";
import { setServerRootPath } from "@/lib/serverRootPath";
vi.mock("../networking", () => ({
fetchMCPServers: vi.fn(),
getMCPOAuthUserCredentialStatus: vi.fn(),
listMCPTools: vi.fn(),
deleteMCPOAuthUserCredential: vi.fn(),
}));
vi.mock("@/hooks/useUserMcpOAuthFlow", () => ({
useUserMcpOAuthFlow: () => ({ startOAuthFlow: vi.fn(), status: "idle" }),
}));
const servers = [
{
server_id: "s-ext",
server_name: "external_logo",
auth_type: "none",
mcp_info: { server_name: "external_logo", logo_url: "https://cdn.example.com/ext.png" },
},
{
server_id: "s-local",
server_name: "local_logo",
auth_type: "none",
mcp_info: { server_name: "local_logo", logo_url: "/ui/assets/logos/github.svg" },
},
{
server_id: "s-none",
server_name: "no_logo",
auth_type: "none",
},
] as MCPServer[];
const renderPanel = () =>
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MCPAppsPanel accessToken="tok" selectedServers={[]} onChange={vi.fn()} />
</QueryClientProvider>,
);
describe("MCPAppsPanel logos", () => {
afterEach(() => {
setServerRootPath("/");
});
it("resolves backend logo_url values in the server grid", async () => {
setServerRootPath("/litellm");
vi.mocked(fetchMCPServers).mockResolvedValue(servers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderPanel();
expect(await screen.findByText("external_logo")).toBeInTheDocument();
expect(screen.getByAltText("external_logo logo")).toHaveAttribute("src", "https://cdn.example.com/ext.png");
expect(screen.getByAltText("local_logo logo")).toHaveAttribute("src", "/litellm/ui/assets/logos/github.svg");
});
it("renders a colored letter avatar for servers without logo_url", async () => {
vi.mocked(fetchMCPServers).mockResolvedValue(servers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderPanel();
expect(await screen.findByText("no_logo")).toBeInTheDocument();
expect(screen.queryByAltText("no_logo logo")).not.toBeInTheDocument();
expect(screen.getByText("N")).toBeInTheDocument();
});
it("resolves the logo_url in the detail header", async () => {
setServerRootPath("/litellm");
vi.mocked(fetchMCPServers).mockResolvedValue(servers);
vi.mocked(listMCPTools).mockResolvedValue({ tools: [] });
renderPanel();
fireEvent.click(await screen.findByText("local_logo"));
expect(await screen.findByRole("heading", { name: "local_logo" })).toBeInTheDocument();
expect(screen.getByAltText("local_logo logo")).toHaveAttribute("src", "/litellm/ui/assets/logos/github.svg");
});
});
const connectServers = [
{
server_id: "s-m2m",
server_name: "service_tool",
auth_type: "oauth2",
oauth2_flow: "client_credentials",
connected_app_reachable: true,
},
{
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();
expect(screen.getByText("service_tool")).toBeInTheDocument();
expect(screen.queryByText("Connect", { exact: true })).not.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 })).toHaveTextContent("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

@ -1,12 +1,13 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Search, ArrowLeft, ChevronRight, Wrench, CheckCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
deleteMCPOAuthUserCredential,
@ -24,7 +25,7 @@ import {
import { Logo } from "@/components/molecules/logo/Logo";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow";
import { pendingUserMcpOAuthServerId, useUserMcpOAuthFlow, type UserMcpOAuthStatus } from "@/hooks/useUserMcpOAuthFlow";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
interface OAuth2ConnectButtonProps {
@ -91,13 +92,58 @@ export const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
);
};
export interface ConnectedServers {
on: string[];
all: string[];
}
export type ServerListReport = ConnectedServers | "loading" | "failed" | "saving-login";
const savingReturnedLogin = (serverId: string, status: UserMcpOAuthStatus): boolean =>
serverId !== "" && (status === "idle" || status === "exchanging");
const listReport = (
loading: boolean,
loadFailed: boolean,
savingLogin: boolean,
servers: ConnectedServers,
): ServerListReport => {
if (savingLogin) return "saving-login";
if (loading) return "loading";
if (loadFailed) return "failed";
return servers;
};
interface Props {
accessToken: string;
selectedServers: string[];
onChange: (servers: string[]) => void;
connectMode?: boolean;
offServerIds?: readonly string[];
onOffServerIdsChange?: (serverIds: string[]) => void;
lockedOn?: LockedOn;
onConnectedServers?: (servers: ServerListReport) => void;
}
const NO_SERVER_IDS: readonly string[] = [];
export type LockedOn = readonly string[] | "all" | undefined;
const isLockedOn = (lockedOn: LockedOn, serverId: string): boolean =>
lockedOn === "all" || (lockedOn?.includes(serverId) ?? false);
const CHAT_LABELS = {
tab: "chat.mcpAppsPanel.tabs.connected",
on: "chat.mcpAppsPanel.info.connected",
off: "chat.mcpAppsPanel.info.notConnected",
} as const;
const CONNECT_LABELS = {
tab: "chat.mcpAppsPanel.tabs.on",
on: "chat.mcpAppsPanel.info.on",
off: "chat.mcpAppsPanel.info.off",
} as const;
const labelsFor = (connectMode: boolean | undefined) => (connectMode ? CONNECT_LABELS : CHAT_LABELS);
const AVATAR_COLORS = [
"#1677ff",
"#52c41a",
@ -121,10 +167,20 @@ type TabKey = "all" | "connected";
const TOOLS_FETCH_CONCURRENCY = 5;
const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange, connectMode }) => {
const MCPAppsPanel: React.FC<Props> = ({
accessToken,
selectedServers,
onChange,
connectMode,
offServerIds = NO_SERVER_IDS,
onOffServerIdsChange,
lockedOn,
onConnectedServers,
}) => {
const { t } = useTranslation();
const [servers, setServers] = useState<MCPServer[]>([]);
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
@ -169,6 +225,15 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
[connectUnavailabilityLabel],
);
const [returningServerId] = useState(() => (connectMode ? pendingUserMcpOAuthServerId() : null) ?? "");
const returningOAuth = useUserMcpOAuthFlow({
accessToken,
serverId: returningServerId,
onSuccess: useCallback(() => {
setOauthConnected((prev) => new Set(prev).add(returningServerId));
}, [returningServerId]),
});
const fetchToolCount = useCallback(
async (server: MCPServer, isCurrentLoad: () => boolean) => {
try {
@ -218,6 +283,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
const oauthServers = reachable.filter((s) => getMcpOAuthMode(s) === "authorization_code");
commitServers(reachable);
setOauthChecking(new Set(oauthServers.map((s) => s.server_id)));
setLoadFailed(false);
setLoading(false);
oauthServers.forEach((s) => checkOauthCredential(s, isCurrentLoad));
@ -235,6 +301,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
.catch(() => {
if (isCurrentLoad()) {
commitServers([]);
setLoadFailed(true);
setLoading(false);
}
});
@ -292,6 +359,63 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
}
};
const isOn = (server: MCPServer) => {
if (connectUnavailabilityLabel(server) !== null) return false;
if (!connectMode) return selectedServers.includes(nameOf(server));
return isLockedOn(lockedOn, server.server_id) || !offServerIds.includes(server.server_id);
};
const setServerOn = (server: MCPServer, on: boolean) =>
onOffServerIdsChange?.(
on ? offServerIds.filter((id) => id !== server.server_id) : [...offServerIds, server.server_id],
);
const renderServerSwitch = (server: MCPServer) => (
<span className="ml-2 inline-flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{lockedOn !== "all" && isLockedOn(lockedOn, server.server_id) && (
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{t("chat.mcpAppsPanel.requiredByClient")}
</span>
)}
<Switch
checked={isOn(server)}
disabled={isLockedOn(lockedOn, server.server_id)}
onCheckedChange={(checked) => setServerOn(server, checked)}
aria-label={t("chat.mcpAppsPanel.useServer", { serverName: nameOf(server) })}
/>
</span>
);
const renderLoginState = (server: MCPServer) => {
const mode = getMcpOAuthMode(server);
if (mode === "m2m") return <CheckCircle className="h-3.5 w-3.5 text-success shrink-0" />;
if (mode !== "authorization_code") return null;
if (oauthConnected.has(server.server_id)) {
return <CheckCircle className="h-3.5 w-3.5 text-success shrink-0" />;
}
if (
oauthChecking.has(server.server_id) ||
(returningOAuth.status === "exchanging" && server.server_id === returningServerId)
) {
return <Skeleton className="h-6 w-16 shrink-0 rounded-md" />;
}
return (
<>
{connectMode && (
<span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap">
{t("chat.mcpAppsPanel.signInToUse")}
</span>
)}
<OAuth2ConnectButton
server={server}
accessToken={accessToken}
onConnect={(id) => setOauthConnected((prev) => new Set(prev).add(id))}
variant="badge"
/>
</>
);
};
const renderConnectionIndicator = (server: MCPServer) => {
const unavailabilityLabel = connectUnavailabilityLabel(server);
if (unavailabilityLabel !== null) {
@ -299,25 +423,16 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
<span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap">{unavailabilityLabel}</span>
);
}
if (getMcpOAuthMode(server) === "m2m") {
return <CheckCircle className="h-3.5 w-3.5 text-success shrink-0" />;
}
if (getMcpOAuthMode(server) === "authorization_code") {
if (oauthConnected.has(server.server_id)) {
return <CheckCircle className="h-3.5 w-3.5 text-success shrink-0" />;
}
if (oauthChecking.has(server.server_id)) {
return <Skeleton className="h-6 w-16 shrink-0 rounded-md" />;
}
const loginState = renderLoginState(server);
if (connectMode) {
return (
<OAuth2ConnectButton
server={server}
accessToken={accessToken}
onConnect={(id) => setOauthConnected((prev) => new Set(prev).add(id))}
variant="badge"
/>
<div className="flex items-center gap-2 shrink-0">
{loginState}
{renderServerSwitch(server)}
</div>
);
}
if (loginState !== null) return loginState;
if (selectedServers.includes(nameOf(server))) {
return <span className="w-[7px] h-[7px] rounded-full bg-success shrink-0" />;
}
@ -337,16 +452,25 @@ 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) && connectUnavailabilityLabel(s) === null);
const matchesTab = activeTab === "all" || isOn(s);
return matchesQuery && matchesTab;
});
const connectedCount = servers.filter(
(s) => selectedServers.includes(nameOf(s)) && connectUnavailabilityLabel(s) === null,
).length;
const connectedCount = servers.filter(isOn).length;
const labels = labelsFor(connectMode);
const connectedServers = useMemo(() => {
const all = servers.filter((s) => connectUnavailabilityLabel(s) === null).map((s) => s.server_id);
return { on: all.filter((id) => isLockedOn(lockedOn, id) || !offServerIds.includes(id)), all };
}, [servers, offServerIds, lockedOn, connectUnavailabilityLabel]);
useEffect(() => {
onConnectedServers?.(
listReport(loading, loadFailed, savingReturnedLogin(returningServerId, returningOAuth.status), connectedServers),
);
}, [loading, loadFailed, returningServerId, returningOAuth.status, connectedServers, onConnectedServers]);
const emptyStateText = () => {
if (loadFailed && connectMode) return t("chat.mcpAppsPanel.empty.loadFailed");
if (servers.length === 0) {
return connectMode ? t("chat.mcpAppsPanel.empty.connectMode") : t("chat.mcpAppsPanel.empty.noServers");
}
@ -358,19 +482,16 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
if (detailServer) {
const name = nameOf(detailServer);
const isConnected = selectedServers.includes(name);
const isConnected = isOn(detailServer);
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>;
}
const renderDetailLogin = () => {
if (getMcpOAuthMode(detailServer) === "m2m") {
return <span className="text-[13px] text-muted-foreground">{t("chat.mcpAppsPanel.authorized")}</span>;
}
if (getMcpOAuthMode(detailServer) !== "authorization_code") {
if (connectMode) return null;
return (
<Button
variant={isConnected ? "outline" : "default"}
@ -398,7 +519,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
n.delete(detailServer.server_id);
return n;
});
onChangeRef.current(selectedServersRef.current.filter((s) => s !== name));
if (!connectMode) onChangeRef.current(selectedServersRef.current.filter((s) => s !== name));
}}
className="font-semibold h-[38px] min-w-[110px]"
>
@ -418,6 +539,20 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
);
};
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 (!connectMode) return renderDetailLogin();
return (
<div className="flex items-center gap-3 shrink-0">
{renderDetailLogin()}
{renderServerSwitch(detailServer)}
</div>
);
};
return (
<div className="w-full">
<Button
@ -459,10 +594,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
{[
[t("chat.mcpAppsPanel.info.serverId"), detailServer.server_id],
[t("chat.mcpAppsPanel.info.transport"), handleTransport(detailServer.transport, detailServer.spec_path)],
[
t("chat.mcpAppsPanel.info.status"),
isConnected ? t("chat.mcpAppsPanel.info.connected") : t("chat.mcpAppsPanel.info.notConnected"),
],
[t("chat.mcpAppsPanel.info.status"), isConnected ? t(labels.on) : t(labels.off)],
]
.filter(([, v]) => v)
.map(([label, value], i, arr) => (
@ -557,7 +689,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
{t("chat.mcpAppsPanel.tabs.all")}
</TabsTrigger>
<TabsTrigger value="connected" className="rounded-none px-4 py-2 text-[13px]">
{t("chat.mcpAppsPanel.tabs.connected")}
{t(labels.tab)}
{connectedCount > 0 ? ` (${connectedCount})` : ""}
</TabsTrigger>
</TabsList>

View file

@ -4456,6 +4456,8 @@ export interface ConnectFlowStatus {
connected: boolean | null;
requested_scopes?: string[];
servers: { server_id: string; server_name: string; connected: boolean }[] | null;
selectable?: false;
required_server_ids?: string[];
}
/**

View file

@ -2,7 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as networking from "@/components/networking";
import { setSecureItem } from "@/utils/secureStorage";
import { useUserMcpOAuthFlow } from "./useUserMcpOAuthFlow";
import { pendingUserMcpOAuthServerId, useUserMcpOAuthFlow } from "./useUserMcpOAuthFlow";
vi.mock("@/components/networking", () => ({
exchangeMcpOAuthToken: vi.fn(),
@ -68,4 +68,42 @@ describe("user OAuth granted scopes", () => {
);
expect(onSuccess).toHaveBeenCalledOnce();
});
it("keeps the returned login until there is a token to save it with", async () => {
vi.mocked(networking.exchangeMcpOAuthToken).mockResolvedValue({
access_token: "vendor-token",
refresh_token: "vendor-refresh",
});
const onSuccess = vi.fn();
const { result, rerender } = renderHook(
({ accessToken }) => useUserMcpOAuthFlow({ accessToken, serverId: "server-1", onSuccess }),
{ initialProps: { accessToken: "" } },
);
expect(networking.exchangeMcpOAuthToken).not.toHaveBeenCalled();
expect(pendingUserMcpOAuthServerId()).toBe("server-1");
rerender({ accessToken: "caller" });
await waitFor(() => expect(result.current.status).toBe("success"));
expect(networking.storeMCPOAuthUserCredential).toHaveBeenCalledWith("caller", "server-1", expect.anything());
expect(onSuccess).toHaveBeenCalledOnce();
});
});
describe("pending user OAuth return", () => {
beforeEach(() => {
window.sessionStorage.clear();
setSecureItem("litellm-user-mcp-oauth-flow-state", JSON.stringify({ serverId: "server-1" }));
});
it("names the server whose login came back", () => {
setSecureItem("litellm-user-mcp-oauth-result", JSON.stringify({ code: "provider-code" }));
expect(pendingUserMcpOAuthServerId()).toBe("server-1");
});
it("names nothing while the login has not come back", () => {
expect(pendingUserMcpOAuthServerId()).toBeNull();
});
});

View file

@ -69,6 +69,15 @@ const getStorage = (key: string): string | null => {
return getSecureItem(key);
};
export const pendingUserMcpOAuthServerId = (): string | null => {
if (typeof window === "undefined" || getStorage(RESULT_KEY) === null) return null;
try {
return (JSON.parse(getStorage(FLOW_STATE_KEY) ?? "null") as StoredFlowState | null)?.serverId ?? null;
} catch (_) {
return null;
}
};
const grantedScopes = (scope: unknown, requested: string[] | undefined): string[] | undefined =>
typeof scope === "string" ? scope.split(" ").filter(Boolean) : requested;
@ -150,7 +159,7 @@ export const useUserMcpOAuthFlow = ({
}, [accessToken, serverId, serverAlias, scopes, preClientId]);
const resumeOAuthFlow = useCallback(async () => {
if (typeof window === "undefined" || processingRef.current) return;
if (typeof window === "undefined" || processingRef.current || !accessToken) return;
const storedResult = getStorage(RESULT_KEY);
if (!storedResult) return;

View file

@ -9383,11 +9383,15 @@
"toolsLoadFailed": "Could not load tools for {{serverName}}",
"empty": {
"connectMode": "No MCP servers are available to this connection yet. Ask an admin to grant your user or team access.",
"loadFailed": "Could not load your MCP servers. Reload the page to try again.",
"noServers": "No MCP servers configured. Add servers in Tools -> MCP Servers.",
"noneConnected": "No servers connected yet.",
"noMatch": "No servers match your search."
},
"authorized": "Authorized",
"signInToUse": "Sign in to use",
"requiredByClient": "Required by this client",
"useServer": "Include {{serverName}} in this connection",
"disconnect": "Disconnect",
"back": "Back",
"defaultDescription": "MCP server",
@ -9397,13 +9401,15 @@
"transport": "Transport",
"status": "Status",
"connected": "Connected",
"notConnected": "Not connected"
"notConnected": "Not connected",
"on": "On",
"off": "Off"
},
"availableTools": "Available Tools",
"noTools": "No tools available",
"title": "MCP Servers",
"beta": "Beta",
"connectModeSubtitle": "Click a server to see its tools and connect",
"connectModeSubtitle": "Turn off any server this connection should not use. Click a server to see its tools or sign in",
"subtitle": "Browse tools, authenticate once, use in chat",
"loadingTools": "Loading tools...",
"toolsAvailable_one": "{{count}} tool available",
@ -9411,7 +9417,8 @@
"searchPlaceholder": "Search servers...",
"tabs": {
"all": "All",
"connected": "Connected"
"connected": "Connected",
"on": "On"
}
},
"connectFlowBanner": {
@ -9423,7 +9430,8 @@
},
"unscoped": {
"title": "Connect your MCP servers to {{client}}",
"description": "Authorize the servers you want to use below, then click Finish connecting to return to {{client}}."
"description": "{{client}} gets every server that is on. Turn off the ones it should not use, sign in where needed, then click Finish connecting.",
"fixed": "{{client}} will get every server you can reach. Sign in to the ones that need it below, then click Finish connecting to return to {{client}}."
},
"multi": {
"title": "Allow {{client}} to use {{servers}}",
@ -9437,6 +9445,7 @@
},
"connectServer": "Connect {{server}}",
"finish": "Finish connecting",
"nothingOn": "Turn on at least one server to finish connecting.",
"cancel": "Cancel",
"manualDelivery": "My client is on a remote or SSH machine"
},

View file

@ -1408,6 +1408,7 @@ export interface paths {
* (``delivery=manual``). POST plus the per-flow HttpOnly cookie set at /authorize; an
* anonymous or bad-flow request just 400s. The native-client consent page adds
* ``decision`` (approve or deny) and the ``team_id`` the credential is attributed to.
* Each ``server`` field is the id of a server the connect page shows as on.
*/
post: operations["authorize_complete_authorize_complete_post"];
delete?: never;
@ -29236,6 +29237,11 @@ export interface components {
delivery?: string | null;
/** Flow */
flow: string;
/**
* Server
* @default []
*/
server: string[];
/** Team Id */
team_id?: string | null;
};

View file

@ -12211,6 +12211,90 @@ def test_gateway_connect_rechecks_session_after_authorize(
assert parse_qs(urlparse(response.headers["location"]).query)["code"]
@pytest.mark.parametrize(
("resource", "sealed_ids", "selectable"),
[
(None, ("alpha-id", "beta-id"), "absent"),
("http://testserver", ("alpha-id", "beta-id"), "absent"),
("http://testserver/mcp", ("alpha-id", "beta-id"), "absent"),
("http://testserver/mcp/alpha,beta", None, False),
],
)
def test_gateway_connect_complete_reads_each_picked_server(
monkeypatch: pytest.MonkeyPatch,
browser_session_database: _BrowserSessionDatabase,
resource: str | None,
sealed_ids: tuple[str, ...] | None,
selectable: Literal["absent", False],
) -> None:
import json
from urllib.parse import parse_qs, urlparse
from litellm.models.object_permission import LiteLLM_ObjectPermissionTable
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.mcp_server.gateway_dcr_flow import GATEWAY_AUTH_CODE_PREFIX
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.proxy.proxy_server import user_api_key_cache
monkeypatch.setattr(global_mcp_server_manager, "registry", global_mcp_server_manager.registry.copy())
client, _, _ = _native_client_app(monkeypatch)
user_api_key_cache.set_cache(
"u1",
LiteLLM_UserTable(
user_id="u1",
object_permission=LiteLLM_ObjectPermissionTable(
object_permission_id="u1-mcp", mcp_servers=["alpha-id", "beta-id"]
),
),
)
for name in ("alpha", "beta"):
server = _create_oauth2_server(server_id=f"{name}-id", name=name, server_name=name, alias=name)
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
client.cookies.set("token", _browser_session_cookie(monkeypatch, "u1", session_key="sk-browser-session"))
registered: Final = client.post("/register", json={"redirect_uris": ["http://127.0.0.1/callback"]})
client_id: Final = registered.json()["client_id"]
resource_param: Final = {"resource": resource} if resource else {}
authorized: Final = client.get(
"/authorize/mcp-session",
params={
"client_id": client_id,
"redirect_uri": "http://127.0.0.1/callback",
"response_type": "code",
"code_challenge": _s256("v" * 43),
"code_challenge_method": "S256",
**resource_param,
},
follow_redirects=False,
)
flow: Final = parse_qs(urlparse(authorized.headers["location"]).query)["connect_flow"][0]
described: Final = client.get("/authorize/flow", params={"flow": flow})
assert described.json().get("selectable", "absent") == selectable
response: Final = client.post(
"/authorize/complete", data={"flow": flow, "server": ["alpha-id", "beta-id"]}, follow_redirects=False
)
assert response.status_code == 303, response.text
code: Final = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
sealed: Final = decrypt_value_helper(
code.removeprefix(GATEWAY_AUTH_CODE_PREFIX), "gateway_authorization_code", return_original_value=False
)
assert isinstance(sealed, str)
granted: Final = json.loads(sealed).get("resource_server_ids")
assert (None if granted is None else tuple(granted)) == sealed_ids
token: Final = client.post(
"/token",
data={
"grant_type": "authorization_code",
"client_id": client_id,
"code": code,
"redirect_uri": "http://127.0.0.1/callback",
"code_verifier": "v" * 43,
**resource_param,
},
)
assert token.status_code == 200, token.text
@pytest.mark.parametrize("authorize_path", ["/authorize/mcp-session", "/authorize"])
def test_gateway_authorize_routes_forward_server_scope(monkeypatch: pytest.MonkeyPatch, authorize_path: str) -> None:
from http.cookies import SimpleCookie

View file

@ -4,6 +4,7 @@ import hashlib
import json
import re
from base64 import urlsafe_b64encode
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
from typing import Final
@ -28,6 +29,7 @@ from litellm.proxy.mcp_server.gateway_dcr_flow import (
TOKEN_EXCHANGE_GRANT_TYPE,
AssertionIdentity,
ConsentTeam,
LookupServerReachability,
MintedProxyCredential,
SubjectIdentity,
SubjectTokenRefusal,
@ -63,6 +65,7 @@ from litellm.proxy.mcp_server.outbound_credentials.session_token import (
mint_session_refresh_token,
mint_session_token,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
MASTER_KEY = "sk-gateway-dcr-flow-tests"
REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback"
@ -3499,3 +3502,336 @@ def test_aggregate_metadata_advertises_basic_client_auth_only_with_the_grant_tha
assert isinstance(methods, list)
assert ("client_secret_basic" in methods) is jwt_bearer_available
assert "none" in methods and "client_secret_post" in methods
async def _authorized_flow(servers: Sequence[MCPServer], scope: str | None = None) -> tuple[str, Response]:
from unittest.mock import patch
client_id: Final = (await _register([REDIRECT_URI]))["client_id"]
with patch(_MANAGER_PATCH) as manager:
manager.get_mcp_server_by_identifier.side_effect = {server.name: server for server in servers}.get
return client_id, _scoped_authorize(client_id, "https://llm.example.com/mcp", scope=scope)
async def _finish(
authorized: Response,
selected: Sequence[str],
servers: Sequence[MCPServer],
cache: DualCache,
decision: str | None = None,
reachable: LookupServerReachability | None = None,
) -> Response:
from unittest.mock import patch
handle, cookies = _flow_cookie_from(authorized)
async def vendor_credential(user_id: str, server_id: str, requested_scopes: tuple[str, ...] = ()) -> str:
return "present"
with patch(_MANAGER_PATCH) as manager:
manager.get_mcp_server_by_identifier.side_effect = {server.name: server for server in servers}.get
manager.get_mcp_server_by_id.side_effect = {server.server_id: server for server in servers}.get
manager.get_registry.return_value = {server.server_id: server for server in servers}
return await complete_connect_flow(
request=_request("/authorize/complete", cookies=cookies, method="POST"),
flow_handle=handle,
session_user_id="u1",
cache=cache,
decision=decision,
lookup_vendor_credential=vendor_credential,
lookup_server_reachability=reachable or _ServerReachability(),
selected_server_ids=selected,
master_key=MASTER_KEY,
)
async def _complete_with_selection(
selected: Sequence[str],
servers: Sequence[MCPServer],
decision: str | None = None,
scope: str | None = None,
reachable: LookupServerReachability | None = None,
) -> tuple[str, Response]:
client_id, authorized = await _authorized_flow(servers, scope)
return client_id, await _finish(authorized, selected, servers, DualCache(), decision, reachable)
def _sealed_code_from(completed: Response) -> tuple[str, Mapping[str, object]]:
code: Final = parse_qs(urlparse(completed.headers["location"]).query)["code"][0]
return code, _sealed_wire_json(code, GATEWAY_AUTH_CODE_PREFIX, "gateway_authorization_code")
async def _session_servers(code: str, client_id: str) -> tuple[str, ...] | None:
token_response: Final = await _redeem(code, client_id, resource="https://llm.example.com/mcp")
assert token_response.status_code == 200
return _opened_principal(json.loads(token_response.body)).resource_server_ids
CATALOG: Final = _scoped_mcp_server(name="catalog", auth_type="api_key")
WAREHOUSE: Final = _scoped_mcp_server(name="warehouse", oauth2_flow="client_credentials")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"picked",
[
_scoped_mcp_server(name="tracker"),
_scoped_mcp_server(name="delegated", delegate_auth_to_upstream=True),
_scoped_mcp_server(name="relayed", per_server_oauth_discovery=True),
_scoped_mcp_server(name="headers", auth_type="api_key", extra_headers=["Authorization"]),
WAREHOUSE,
],
)
async def test_picked_servers_scope_the_session_token(picked: MCPServer) -> None:
inventory: Final = _scoped_mcp_server(name="inventory", auth_type="api_key")
client_id, completed = await _complete_with_selection(
("catalog-id", picked.server_id, "catalog-id"), (CATALOG, picked, inventory)
)
assert completed.status_code == 303
code, sealed = _sealed_code_from(completed)
assert sealed["resource_server_ids"] == ["catalog-id", picked.server_id]
assert await _session_servers(code, client_id) == ("catalog-id", picked.server_id)
@pytest.mark.asyncio
async def test_servers_that_were_not_picked_stay_out() -> None:
unpicked: Final = _scoped_mcp_server(name="tracker")
client_id, completed = await _complete_with_selection(("catalog-id",), (CATALOG, WAREHOUSE, unpicked))
assert completed.status_code == 303
code, _ = _sealed_code_from(completed)
assert await _session_servers(code, client_id) == ("catalog-id",)
@pytest.mark.asyncio
@pytest.mark.parametrize("selected", [(), ("",), (" ", "\t")])
async def test_without_a_pick_the_unscoped_flow_mints_an_unscoped_code(selected: tuple[str, ...]) -> None:
_, completed = await _complete_with_selection(selected, (CATALOG, WAREHOUSE))
assert completed.status_code == 303
_, sealed = _sealed_code_from(completed)
assert "resource_server_ids" not in sealed
assert "resource_server_id" not in sealed
@pytest.mark.asyncio
async def test_picks_of_deleted_servers_are_dropped() -> None:
client_id, completed = await _complete_with_selection(("deleted-id", "catalog-id"), (CATALOG,))
assert completed.status_code == 303
code, _ = _sealed_code_from(completed)
assert await _session_servers(code, client_id) == ("catalog-id",)
@pytest.mark.asyncio
async def test_finish_refuses_when_every_pick_is_gone() -> None:
_, completed = await _complete_with_selection(("deleted-id",), (CATALOG, WAREHOUSE))
assert completed.status_code == 400
assert json.loads(completed.body) == {
"error": "invalid_request",
"error_description": "the requested MCP server is no longer available",
}
@pytest.mark.asyncio
async def test_a_refused_finish_can_be_retried_with_picks_that_can_be_granted() -> None:
client_id, authorized = await _authorized_flow((CATALOG,))
cache: Final = DualCache()
refused: Final = await _finish(authorized, ("deleted-id",), (CATALOG,), cache)
assert refused.status_code == 400
retried: Final = await _finish(authorized, ("catalog-id",), (CATALOG,), cache)
assert retried.status_code == 303
code, _ = _sealed_code_from(retried)
assert await _session_servers(code, client_id) == ("catalog-id",)
@pytest.mark.asyncio
@pytest.mark.parametrize("selected", [("catalog-id",), ("deleted-id", "catalog-id")])
async def test_finish_refuses_when_every_known_pick_is_inaccessible(selected: tuple[str, ...]) -> None:
_, completed = await _complete_with_selection(selected, (CATALOG, WAREHOUSE), reachable=_ServerReachability(False))
assert completed.status_code == 400
assert json.loads(completed.body) == {
"error": "invalid_request",
"error_description": "you do not have access to the requested MCP server",
}
@pytest.mark.asyncio
async def test_inaccessible_picks_are_dropped() -> None:
orders: Final = _scoped_mcp_server(name="orders", auth_type="api_key")
async def reachable(user_id: str, server_id: str) -> bool:
return user_id == "u1" and server_id == CATALOG.server_id
client_id, completed = await _complete_with_selection(
("catalog-id", "orders-id", "deleted-id"), (CATALOG, orders, WAREHOUSE), reachable=reachable
)
assert completed.status_code == 303
code, _ = _sealed_code_from(completed)
assert await _session_servers(code, client_id) == (CATALOG.server_id,)
@pytest.mark.asyncio
async def test_a_server_picked_twice_is_checked_once() -> None:
reachable: Final = _ServerReachability()
client_id, completed = await _complete_with_selection(
("catalog-id", "catalog-id"), (CATALOG, WAREHOUSE), reachable=reachable
)
assert completed.status_code == 303
assert reachable.calls == [("u1", "catalog-id")]
code, _ = _sealed_code_from(completed)
assert await _session_servers(code, client_id) == ("catalog-id",)
@pytest.mark.asyncio
async def test_a_pick_grants_only_that_server_when_another_shares_its_name() -> None:
twin: Final = CATALOG.model_copy(update={"server_id": "catalog-db-id"})
client_id, completed = await _complete_with_selection((twin.server_id,), (CATALOG, twin))
assert completed.status_code == 303
code, _ = _sealed_code_from(completed)
assert await _session_servers(code, client_id) == (twin.server_id,)
@pytest.mark.asyncio
async def test_a_server_name_is_not_taken_as_a_pick() -> None:
_, completed = await _complete_with_selection((CATALOG.name,), (CATALOG,))
assert completed.status_code == 400
assert json.loads(completed.body)["error_description"] == "the requested MCP server is no longer available"
def _fleet_server(index: int) -> MCPServer:
return _scoped_mcp_server(name=f"tool{index}", auth_type="api_key").model_copy(
update={"server_id": f"00000000-0000-4000-8000-{index:012d}"}
)
@pytest.mark.asyncio
async def test_picks_too_many_for_a_session_token_are_refused() -> None:
picked: Final = tuple(_fleet_server(index) for index in range(80))
_, completed = await _complete_with_selection(tuple(server.server_id for server in picked), picked)
assert completed.status_code == 400
assert json.loads(completed.body)["error_description"] == (
"too many MCP servers are turned on for one connection, turn some off and try again"
)
@pytest.mark.asyncio
async def test_a_grant_is_refused_exactly_when_its_refresh_tokens_would_not_fit() -> None:
fleet: Final = tuple(_fleet_server(index) for index in range(69))
finishes: Final = tuple(
[
await _complete_with_selection(tuple(server.server_id for server in fleet[:count]), fleet[:count])
for count in (66, 67, 68, 69)
]
)
assert [completed.status_code for _, completed in finishes] == [303, 303, 400, 400]
client_id, largest_fit = finishes[1]
code, _ = _sealed_code_from(largest_fit)
cache: Final = DualCache()
redeemed: Final = await _redeem(code, client_id, cache=cache, resource="https://llm.example.com/mcp")
assert redeemed.status_code == 200
rotated: Final = await _redeem(
None,
client_id,
cache=cache,
grant_type="refresh_token",
refresh_token=json.loads(redeemed.body)["refresh_token"],
resource="https://llm.example.com/mcp",
)
assert rotated.status_code == 200
@pytest.mark.asyncio
async def test_an_already_scoped_flow_ignores_picks() -> None:
alpha: Final = _scoped_mcp_server(name="alpha", auth_type="api_key")
client_id, completed = await _complete_with_selection(
("catalog-id",), (alpha, CATALOG), scope="litellm:mcp_server:alpha"
)
assert completed.status_code == 303
code, sealed = _sealed_code_from(completed)
assert sealed["resource_server_id"] == "alpha-id"
assert await _session_servers(code, client_id) == ("alpha-id",)
@pytest.mark.asyncio
async def test_the_native_client_consent_flow_ignores_picks() -> None:
client_id: Final = (await _register([LOOPBACK_REDIRECT_URI]))["client_id"]
consent: Final = await _native_authorize(client_id)
completed: Final = await _complete_consent(consent, team_id="team-b", selected_server_ids=("catalog-id",))
assert completed.status_code == 303
sealed: Final = _sealed_wire_json(_code_from(completed), GATEWAY_AUTH_CODE_PREFIX, "gateway_authorization_code")
assert "resource_server_ids" not in sealed
assert "resource_server_id" not in sealed
@pytest.mark.asyncio
async def test_selection_on_an_upstream_step_up_keeps_the_step_up_server() -> None:
from litellm.proxy.mcp_server.oauth_utils import encode_upstream_scope
tracker: Final = _scoped_mcp_server(name="tracker")
client_id, completed = await _complete_with_selection(
("catalog-id",), (tracker, CATALOG), scope=encode_upstream_scope(tracker.server_id, "write")
)
assert completed.status_code == 303
code, sealed = _sealed_code_from(completed)
assert sealed["resource_server_ids"] == ["tracker-id", "catalog-id"]
assert await _session_servers(code, client_id) == ("tracker-id", "catalog-id")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("resource", "step_up", "selectable", "required"),
[
("https://llm.example.com/mcp/alpha,beta", False, False, "absent"),
("https://llm.example.com/mcp/alpha,beta", True, False, "absent"),
("https://llm.example.com/mcp", True, "absent", ["tracker-id"]),
("https://llm.example.com/mcp", False, "absent", "absent"),
],
)
async def test_the_page_is_told_which_switches_it_cannot_change(
resource: str, step_up: bool, selectable: bool | str, required: list[str] | str
) -> None:
from unittest.mock import patch
from litellm.proxy.mcp_server.oauth_utils import encode_upstream_scope
tracker: Final = _scoped_mcp_server(name="tracker")
client_id: Final = (await _register([REDIRECT_URI]))["client_id"]
with patch(_MANAGER_PATCH) as manager:
manager.get_mcp_server_by_identifier.return_value = None
manager.get_mcp_server_by_id.return_value = tracker
authorized: Final = _scoped_authorize(
client_id, resource, scope=encode_upstream_scope(tracker.server_id, "write") if step_up else None
)
async def vendor_credential(user_id: str, server_id: str, requested_scopes: tuple[str, ...] = ()) -> str:
return "present"
described: Final = await _describe_page(authorized, scoped_server=tracker, vendor=vendor_credential)
body: Final = json.loads(described.body)
assert (body["state"], body.get("selectable", "absent")) == ("unscoped", selectable)
assert body.get("required_server_ids", "absent") == required
@pytest.mark.asyncio
async def test_deny_ignores_the_selection() -> None:
_, completed = await _complete_with_selection(("deleted-id",), (), decision="deny")
assert completed.status_code == 303
assert parse_qs(urlparse(completed.headers["location"]).query)["error"] == ["access_denied"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("resource", "error"),
[("https://llm.example.com/mcp", None), (None, None), ("https://other.example.com/x", "invalid_target")],
)
async def test_a_picked_code_redeems_only_for_the_resource_it_was_authorized_for(
resource: str | None, error: str | None
) -> None:
client_id, completed = await _complete_with_selection(("catalog-id",), (CATALOG,))
assert completed.status_code == 303
code, _ = _sealed_code_from(completed)
redeemed: Final = await _redeem(code, client_id, resource=resource)
assert (redeemed.status_code, json.loads(redeemed.body).get("error")) == (
(200, None) if error is None else (400, error)
)