diff --git a/ui/litellm-dashboard/src/app/chat/integrations/page.tsx b/ui/litellm-dashboard/src/app/chat/integrations/page.tsx
index f85dd591199..452c7918bf6 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,6 +32,7 @@ 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 00000000000..188ba00becb
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx
@@ -0,0 +1,33 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import ConnectFlowBanner from "./ConnectFlowBanner";
+
+vi.mock("@/components/networking", () => ({
+ getProxyBaseUrl: () => "https://gateway.example.com",
+}));
+
+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);
+ });
+});
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 00000000000..f4728b540c8
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import React from "react";
+import { CheckCircle } from "lucide-react";
+import { getProxyBaseUrl } from "@/components/networking";
+
+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
+ * deliberate finish step.
+ *
+ * "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.
+ */
+const ConnectFlowBanner: React.FC = ({ flowHandle, clientOrigin }) => {
+ const action = `${getProxyBaseUrl()}/authorize/complete`;
+ const clientLabel = clientOrigin ?? "the application";
+
+ return (
+
+
+
+
+
+
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}.
+
+
+
+
+
+
+ );
+};
+
+export default ConnectFlowBanner;