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.
+
+
+
+
+
+
+ );
+};
+
+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);