diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index fa2287bdaef5..52d8295d798c 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1797,7 +1797,7 @@ }, "src/components/chat/MCPAppsPanel.tsx": { "no-nested-ternary": { - "count": 7 + "count": 6 } }, "src/components/chat/MCPConnectPicker.tsx": { diff --git a/ui/litellm-dashboard/src/app/chat/integrations/page.tsx b/ui/litellm-dashboard/src/app/chat/integrations/page.tsx index f85dd591199c..30ce62d8081f 100644 --- a/ui/litellm-dashboard/src/app/chat/integrations/page.tsx +++ b/ui/litellm-dashboard/src/app/chat/integrations/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useChatShell } from "@/contexts/ChatShellContext"; import MCPAppsPanel from "@/components/chat/MCPAppsPanel"; +import ConnectFlowBanner from "@/components/chat/ConnectFlowBanner"; // useSearchParams() requires a Suspense boundary for static export. function IntegrationsPageContent() { @@ -11,6 +12,13 @@ function IntegrationsPageContent() { const router = useRouter(); const searchParams = useSearchParams(); const oauthReturn = searchParams.get("mcpOauthReturn"); + // Set by the gateway DCR authorize when a DCR client sends the user here to + // authorize servers before finishing sign-in (see gateway_dcr_flow.py). The + // handle keys the sealed per-flow cookie; connect_client is the client origin + // for display only. connect_flow is NOT cleaned from the URL: the finish form + // needs it, and the sealed cookie (not the URL) is the security boundary. + const connectFlow = searchParams.get("connect_flow"); + const connectClient = searchParams.get("connect_client"); // Clean up the OAuth return param after it's been consumed — real routing means // we no longer need it to pick a tab, but it should not linger in the address bar. @@ -24,7 +32,13 @@ function IntegrationsPageContent() { return (
- + {connectFlow && } +
); } diff --git a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx new file mode 100644 index 000000000000..9baf52966a61 --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import ConnectFlowBanner from "./ConnectFlowBanner"; +import { PERSERVER_CONNECTING_KEY } from "@/hooks/mcpOAuthUtils"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "https://gateway.example.com", +})); + +afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); +}); + +describe("ConnectFlowBanner", () => { + it("posts the flow handle to the proxy /authorize/complete as a full-page form", () => { + const { container } = render(); + + const form = container.querySelector("form")!; + expect(form.getAttribute("method")).toBe("POST"); + expect(form.getAttribute("action")).toBe("https://gateway.example.com/authorize/complete"); + + const hidden = form.querySelector('input[name="flow"]') as HTMLInputElement; + expect(hidden.value).toBe("flow-handle-123"); + // No token, code, or secret is ever placed in the form; the sealed cookie carries them. + expect(form.innerHTML).not.toContain("token"); + }); + + it("shows the client origin so the user knows what they are connecting to", () => { + render(); + expect(screen.getAllByText(/claude\.ai/).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: /finish connecting/i })).toBeInTheDocument(); + }); + + it("falls back to a generic label when the client origin is unknown", () => { + render(); + expect(screen.getAllByText(/the application/).length).toBeGreaterThan(0); + }); + + it("best-effort auto-finishes on pagehide (closing the tab)", () => { + const beaconMock = vi.fn(() => true); + vi.stubGlobal("navigator", { ...navigator, sendBeacon: beaconMock }); + render(); + + window.dispatchEvent(new Event("pagehide")); + + expect(beaconMock).toHaveBeenCalledTimes(1); + const [url, body] = beaconMock.mock.calls[0] as unknown as [string, URLSearchParams]; + expect(url).toBe("https://gateway.example.com/authorize/complete"); + expect(body.toString()).toContain("flow=flow-xyz"); + }); + + it("does NOT auto-finish while a per-server connect is navigating away", () => { + const beaconMock = vi.fn(() => true); + vi.stubGlobal("navigator", { ...navigator, sendBeacon: beaconMock }); + render(); + + // the per-server connect flow sets this right before it navigates to the upstream IdP + sessionStorage.setItem(PERSERVER_CONNECTING_KEY, "1"); + window.dispatchEvent(new Event("pagehide")); + + expect(beaconMock).not.toHaveBeenCalled(); + }); + + it("does NOT double-fire the auto-finish after the button was pressed", () => { + const beaconMock = vi.fn(() => true); + vi.stubGlobal("navigator", { ...navigator, sendBeacon: beaconMock }); + const { container } = render(); + + // jsdom does not submit forms; fire the form's submit so onSubmit marks it finished + fireEvent.submit(container.querySelector("form")!); + window.dispatchEvent(new Event("pagehide")); + + expect(beaconMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx new file mode 100644 index 000000000000..a46b4e59959b --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import { CheckCircle } from "lucide-react"; +import { getProxyBaseUrl } from "@/components/networking"; +import { PERSERVER_CONNECTING_KEY } from "@/hooks/mcpOAuthUtils"; + +interface Props { + flowHandle: string; + clientOrigin: string | null; +} + +/** + * The interlude shown when a DCR client (Claude Desktop, MCP Inspector) sends the user + * through the gateway sign-in and lands them on the apps grid to authorize servers. The + * grid below authorizes individual servers into the per-user vault; this banner is the + * finish step that returns the user to the client. + * + * Finishing happens two ways, both hitting the proxy's /authorize/complete, which mints the + * gateway authorization code and 303-redirects to the DCR client's own redirect URI: + * - The explicit "Finish connecting" button is a native form POST, so the full-page + * navigation carries the HttpOnly per-flow cookie and follows the cross-origin redirect + * to the client's loopback. This is the reliable path. + * - Closing (or navigating away from) the tab fires a best-effort navigator.sendBeacon to the + * same endpoint. The browser follows the 303 to the client's loopback, so in most browsers + * the code still reaches the client without an explicit click. This is a convenience, not a + * consent gate: consent already happened at sign-in, so returning the user is safe. It is + * skipped while a per-server connect is navigating away (that is not leaving the flow), + * and after the button was pressed (which already delivers the code). + */ +const ConnectFlowBanner: React.FC = ({ flowHandle, clientOrigin }) => { + const action = `${getProxyBaseUrl()}/authorize/complete`; + const clientLabel = clientOrigin ?? "the application"; + const finishedRef = useRef(false); + + useEffect(() => { + sessionStorage.removeItem(PERSERVER_CONNECTING_KEY); + + const autoFinishOnLeave = () => { + if (finishedRef.current) return; + if (sessionStorage.getItem(PERSERVER_CONNECTING_KEY) === "1") return; + if (typeof navigator.sendBeacon === "function") { + navigator.sendBeacon(action, new URLSearchParams({ flow: flowHandle })); + } + }; + window.addEventListener("pagehide", autoFinishOnLeave); + return () => window.removeEventListener("pagehide", autoFinishOnLeave); + }, [action, flowHandle]); + + return ( +
+
+
+ +
+

Connect your MCP servers to {clientLabel}

+

+ Authorize the servers you want to use below, then finish connecting to return to {clientLabel}. Closing + this tab finishes for you. +

+
+
+
(finishedRef.current = true)}> + + +
+
+
+ ); +}; + +export default ConnectFlowBanner; diff --git a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx index 867522090d2d..ac26e90dfb63 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx @@ -13,7 +13,7 @@ import { getMCPOAuthUserCredentialStatus, listMCPTools, } from "../networking"; -import { AUTH_TYPE, MCPServer, MCPTool, handleTransport } from "../mcp_tools/types"; +import { AUTH_TYPE, MCPServer, MCPTool, handleTransport, isUnsupportedOnGatewayConnect } from "../mcp_tools/types"; import MessageManager from "@/components/molecules/message_manager"; import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow"; @@ -70,6 +70,7 @@ interface Props { accessToken: string; selectedServers: string[]; onChange: (servers: string[]) => void; + connectMode?: boolean; } const AVATAR_COLORS = [ @@ -95,7 +96,7 @@ type TabKey = "all" | "connected"; const TOOLS_FETCH_CONCURRENCY = 5; -const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange }) => { +const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange, connectMode }) => { const [servers, setServers] = useState([]); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(""); @@ -105,6 +106,7 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange const [toolCounts, setToolCounts] = useState>({}); const [loadingCounts, setLoadingCounts] = useState(false); const [oauthConnected, setOauthConnected] = useState>(new Set()); + const [oauthChecking, setOauthChecking] = useState>(new Set()); const serversRef = useRef([]); useEffect(() => { @@ -147,6 +149,14 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange } } catch { // ignore + } finally { + if (!fetchLoadCancelledRef.current) { + setOauthChecking((prev) => { + const next = new Set(prev); + next.delete(server.server_id); + return next; + }); + } } }, [accessToken], @@ -159,9 +169,13 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange .then(async (serverData) => { if (fetchLoadCancelledRef.current) return; const list: MCPServer[] = Array.isArray(serverData) ? serverData : serverData?.data ?? []; + const oauthServers = list.filter((s) => s.auth_type === AUTH_TYPE.OAUTH2); setServers(list); + setOauthChecking(new Set(oauthServers.map((s) => s.server_id))); setLoading(false); + oauthServers.forEach((s) => checkOauthCredential(s)); + setLoadingCounts(true); const chunks = Array.from({ length: Math.ceil(list.length / TOOLS_FETCH_CONCURRENCY) }, (_, i) => list.slice(i * TOOLS_FETCH_CONCURRENCY, (i + 1) * TOOLS_FETCH_CONCURRENCY), @@ -171,9 +185,6 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange await Promise.allSettled(chunk.map((s) => fetchToolCount(s))); } if (!fetchLoadCancelledRef.current) setLoadingCounts(false); - - const oauthServers = list.filter((s) => s.auth_type === AUTH_TYPE.OAUTH2); - oauthServers.forEach((s) => checkOauthCredential(s)); }) .catch(() => { if (!fetchLoadCancelledRef.current) { @@ -230,6 +241,36 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange } }; + const renderConnectionIndicator = (server: MCPServer) => { + if (connectMode && isUnsupportedOnGatewayConnect(server.auth_type)) { + return ( + + Not supported on this connection + + ); + } + if (server.auth_type === AUTH_TYPE.OAUTH2) { + if (oauthConnected.has(server.server_id)) { + return ; + } + if (oauthChecking.has(server.server_id)) { + return ; + } + return ( + setOauthConnected((prev) => new Set(prev).add(id))} + variant="badge" + /> + ); + } + if (selectedServers.includes(nameOf(server))) { + return ; + } + return null; + }; + const { data: detailToolsResult, isLoading: loadingTools } = useQuery({ queryKey: ["mcp-apps-panel-detail-tools", detailServer?.server_id], queryFn: () => listMCPTools(accessToken, detailServer!.server_id), @@ -396,24 +437,30 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange

MCP Servers

- - Beta - -
-
-

Browse tools, authenticate once, use in chat

- {loadingCounts ? ( - - - Loading tools... + {!connectMode && ( + + Beta - ) : totalTools > 0 ? ( - - - {totalTools} tool{totalTools !== 1 ? "s" : ""} available - - ) : null} + )}
+ {connectMode ? ( +

Click a server to see its tools and connect

+ ) : ( +
+

Browse tools, authenticate once, use in chat

+ {loadingCounts ? ( + + + Loading tools... + + ) : totalTools > 0 ? ( + + + {totalTools} tool{totalTools !== 1 ? "s" : ""} available + + ) : null} +
+ )}
@@ -464,10 +511,10 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange
{filtered.map((server, idx) => { const name = nameOf(server); - const isConnected = selectedServers.includes(name); const color = getAvatarColor(name); const isLeftCol = idx % 2 === 0; const count = toolCounts[name]; + const unsupported = !!connectMode && isUnsupportedOnGatewayConnect(server.auth_type); return (
= ({ accessToken, selectedServers, onChange onClick={() => setDetailServer(server)} className={`flex items-center gap-3 p-4 bg-card cursor-pointer transition-colors hover:bg-accent/30 min-w-0 ${ isLeftCol ? "border-r" : "" - } ${Math.floor(idx / 2) < Math.floor((filtered.length - 1) / 2) ? "border-b" : ""}`} + } ${Math.floor(idx / 2) < Math.floor((filtered.length - 1) / 2) ? "border-b" : ""} ${ + unsupported ? "opacity-50" : "" + }`} > {server.mcp_info?.logo_url ? ( = ({ accessToken, selectedServers, onChange ) : null}
- {server.auth_type === AUTH_TYPE.OAUTH2 ? ( - oauthConnected.has(server.server_id) ? ( - - ) : ( - { - setOauthConnected((prev) => new Set(prev).add(id)); - }} - variant="badge" - /> - ) - ) : isConnected ? ( - - ) : null} + {renderConnectionIndicator(server)}
); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx index 7ce803d583c9..175df00fd665 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx @@ -13,6 +13,7 @@ import { preservedDeclaredAppCredentials, withoutMintedTokenCredentials, credentialAuthClass, + isUnsupportedOnGatewayConnect, } from "./types"; describe("getOAuthAuthorizationIdentity", () => { @@ -231,3 +232,23 @@ describe("credentialAuthClass", () => { expect(credentialAuthClass(null)).toBeNull(); }); }); + +describe("isUnsupportedOnGatewayConnect", () => { + it("flags the modes that need a caller-supplied upstream token or subject", () => { + // client-forwarded: caller presents the upstream Authorization per call + expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.TRUE_PASSTHROUGH)).toBe(true); + expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.OAUTH_DELEGATE)).toBe(true); + // OBO: caller's own IdP token is the exchange subject, which the session bearer is not + expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE)).toBe(true); + }); + + it("does not flag modes the gateway can serve from server-side state or interactive vaulting", () => { + // interactive authorization_code is the one mode the connect grid vaults per user + expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.OAUTH2)).toBe(false); + // server-configured credentials need no per-user connect + expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.API_KEY)).toBe(false); + expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.NONE)).toBe(false); + expect(isUnsupportedOnGatewayConnect(null)).toBe(false); + expect(isUnsupportedOnGatewayConnect(undefined)).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 04766aad7b47..1b8277dad048 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -52,6 +52,15 @@ export const AUTH_TYPE = { export const isClientForwardedTokenMode = (authType?: string | null): boolean => authType === AUTH_TYPE.TRUE_PASSTHROUGH || authType === AUTH_TYPE.OAUTH_DELEGATE; +// Auth modes that cannot be used through the gateway aggregate connect flow, where the client holds +// only an identity-only session bearer and upstream credentials are resolved server-side from the +// per-user vault. The vault is only populated by interactive authorization_code (oauth2). The +// client-forwarded modes need the caller to present the upstream Authorization per call, and +// oauth2_token_exchange (OBO) needs the caller's own IdP token as the subject to exchange; the +// session bearer is neither, so none of these can complete a tool call on this connection. +export const isUnsupportedOnGatewayConnect = (authType?: string | null): boolean => + isClientForwardedTokenMode(authType) || authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE; + export const OAUTH_FLOW = { INTERACTIVE: "interactive", M2M: "m2m", diff --git a/ui/litellm-dashboard/src/hooks/mcpOAuthUtils.ts b/ui/litellm-dashboard/src/hooks/mcpOAuthUtils.ts index 83b2cb21bede..37046ca977a8 100644 --- a/ui/litellm-dashboard/src/hooks/mcpOAuthUtils.ts +++ b/ui/litellm-dashboard/src/hooks/mcpOAuthUtils.ts @@ -16,6 +16,14 @@ import { getProxyBaseUrl, serverRootPath } from "@/components/networking"; */ export const TOOLS_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-tools-state"; +/** + * sessionStorage flag set by useUserMcpOAuthFlow right before it navigates the whole page + * to the upstream IdP to authorize one server. ConnectFlowBanner's auto-finish-on-close + * handler skips while this is set, so authorizing a server is not mistaken for the user + * leaving the gateway DCR connect flow. + */ +export const PERSERVER_CONNECTING_KEY = "litellm-mcp-perserver-connecting"; + /** * Build the OAuth callback URL for the current UI deployment. * diff --git a/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx index 13bad2272971..0c54a0c2a479 100644 --- a/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx @@ -23,7 +23,7 @@ import NotificationsManager from "@/components/molecules/notifications_manager"; import { extractErrorMessage } from "@/utils/errorUtils"; import { generateCodeChallenge, generateCodeVerifier } from "@/utils/pkce"; import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; -import { buildCallbackUrl, clearStorage } from "./mcpOAuthUtils"; +import { buildCallbackUrl, clearStorage, PERSERVER_CONNECTING_KEY } from "./mcpOAuthUtils"; export type UserMcpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error"; @@ -135,6 +135,7 @@ export const useUserMcpOAuthFlow = ({ returnUrl.searchParams.set("mcpOauthReturn", "apps"); setStorage(RETURN_URL_KEY, returnUrl.toString()); + sessionStorage.setItem(PERSERVER_CONNECTING_KEY, "1"); window.location.href = authorizeUrl; } catch (err) { const msg = extractErrorMessage(err);