diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index 9de171397db..14a3076c27e 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -1712,7 +1712,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 452c7918bf6..30ce62d8081 100644
--- a/ui/litellm-dashboard/src/app/chat/integrations/page.tsx
+++ b/ui/litellm-dashboard/src/app/chat/integrations/page.tsx
@@ -33,7 +33,12 @@ 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
index 188ba00becb..9baf52966a6 100644
--- a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx
@@ -1,11 +1,17 @@
-import { describe, expect, it, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
+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();
@@ -30,4 +36,41 @@ describe("ConnectFlowBanner", () => {
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
index f4728b540c8..a46b4e59959 100644
--- a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx
@@ -1,8 +1,9 @@
"use client";
-import React from "react";
+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;
@@ -13,17 +14,38 @@ interface Props {
* 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
- * deliberate finish step.
+ * finish step that returns the user to the client.
*
- * "Finish connecting" is a native form POST to the proxy's /authorize/complete, not a
- * fetch: the endpoint 303-redirects the browser back to the DCR client's own redirect URI
- * with the gateway authorization code, and only a full-page navigation carries the
- * HttpOnly per-flow cookie and follows that cross-origin redirect. The flow handle is the
- * only field; the sealed flow cookie set at /authorize holds everything else.
+ * 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 (
@@ -33,12 +55,12 @@ const ConnectFlowBanner: React.FC
= ({ flowHandle, clientOrigin }) => {
Connect your MCP servers to {clientLabel}
- Authorize the servers you want to use below. When you are ready, finish connecting and you will be
- returned to {clientLabel}.
+ Authorize the servers you want to use below, then finish connecting to return to {clientLabel}. Closing
+ this tab finishes for you.
-