From 47f33bda3f8d61388508dc9de1c1470fb20cd528 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 10 Jul 2026 11:47:42 -0700 Subject: [PATCH 1/4] feat(ui): dcr_bridge toggle for client-forwarded MCP auth modes --- .../components/mcp_tools/DcrBridgeToggle.tsx | 40 +++++ .../mcp_tools/create_mcp_server.test.tsx | 163 ++++++++++++++++++ .../mcp_tools/create_mcp_server.tsx | 9 + .../mcp_tools/mcp_server_edit.test.tsx | 152 ++++++++++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 13 ++ .../src/components/mcp_tools/types.tsx | 1 + 6 files changed, 378 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/DcrBridgeToggle.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/DcrBridgeToggle.tsx b/ui/litellm-dashboard/src/components/mcp_tools/DcrBridgeToggle.tsx new file mode 100644 index 000000000000..f1b642293c0b --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/DcrBridgeToggle.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Form, Switch, Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { isClientForwardedTokenMode } from "./types"; + +/** + * DCR-bridge toggle for the client-forwarded token modes (true_passthrough / + * oauth_delegate); self-gates to those two auth types and renders nothing + * otherwise. When on, OAuth-only clients like Claude Desktop can register and + * sign in through the gateway; when off, the gateway relays the upstream + * server's own OAuth metadata instead. `initialChecked` seeds the antd + * Form.Item `initialValue` (not the Switch's DOM defaultChecked): the create + * form defaults it on, the edit form seeds it from the stored value. + */ +export default function DcrBridgeToggle({ + authType, + initialChecked, +}: { + authType?: string | null; + initialChecked?: boolean; +}) { + if (!isClientForwardedTokenMode(authType)) return null; + return ( + + Gateway-hosted sign-in (DCR bridge) + + + + + } + name="dcr_bridge" + valuePropName="checked" + initialValue={initialChecked} + > + + + ); +} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index 02374a3ffa47..a7eed4256d25 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -1509,3 +1509,166 @@ describe("CreateMCPServer oauth2_flow persistence", () => { expect(payload.oauth2_flow).toBeUndefined(); }); }); + +describe("CreateMCPServer dcr_bridge toggle", () => { + beforeEach(() => { + vi.clearAllMocks(); + oauthHook.tokenResponse = null; + oauthHook.onTokenReceived = null; + }); + + const createdServer = { + server_id: "new-cf-server", + server_name: "CF_Server", + alias: "CF_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: "true_passthrough", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }; + + const getDcrToggle = () => document.getElementById("dcr_bridge"); + + async function setupHttpServerForm() { + render(); + await selectAntOption("Transport Type", "Streamable HTTP"); + await waitFor(() => { + expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument(); + }); + await act(async () => { + fireEvent.change(getServerNameInput(), { target: { value: "CF_Server" } }); + }); + await act(async () => { + fireEvent.change(screen.getByPlaceholderText("https://your-mcp-server.com"), { + target: { value: "https://example.com/mcp" }, + }); + }); + } + + async function submitCreate() { + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + await waitFor(() => { + expect(networking.createMCPServer).toHaveBeenCalledTimes(1); + }); + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + return payload; + } + + it.each([["True Passthrough (no LiteLLM auth)"], ["OAuth Delegate (client-supplied upstream token)"]])( + "renders the toggle default-checked when %s is selected", + async (optionLabel) => { + await setupHttpServerForm(); + + await selectAntOption("Authentication", optionLabel); + + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + expect(screen.getByText("Gateway-hosted sign-in (DCR bridge)")).toBeInTheDocument(); + expect(getDcrToggle()).toHaveAttribute("aria-checked", "true"); + }, + ); + + it.each([["None"], ["API Key"], ["OAuth"]])("does not render the toggle for %s", async (optionLabel) => { + await setupHttpServerForm(); + + await selectAntOption("Authentication", optionLabel); + + await waitFor(() => { + expect(screen.queryByText("Gateway-hosted sign-in (DCR bridge)")).not.toBeInTheDocument(); + }); + expect(getDcrToggle()).not.toBeInTheDocument(); + }); + + it.each([ + ["true_passthrough", "True Passthrough (no LiteLLM auth)"], + ["oauth_delegate", "OAuth Delegate (client-supplied upstream token)"], + ])("sends dcr_bridge: true by default on create for %s", async (authType, optionLabel) => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ ...createdServer, auth_type: authType }); + await setupHttpServerForm(); + await selectAntOption("Authentication", optionLabel); + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + + const payload = await submitCreate(); + expect(payload.dcr_bridge).toBe(true); + }); + + it("sends an explicit dcr_bridge: false when the toggle is unchecked", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ ...createdServer, auth_type: "oauth_delegate" }); + await setupHttpServerForm(); + await selectAntOption("Authentication", "OAuth Delegate (client-supplied upstream token)"); + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(getDcrToggle()!); + }); + expect(getDcrToggle()).toHaveAttribute("aria-checked", "false"); + + const payload = await submitCreate(); + expect(payload.dcr_bridge).toBe(false); + }); + + it.each([ + ["none", "None"], + ["api_key", "API Key"], + ["oauth2", "OAuth"], + ])("forces an explicit dcr_bridge: false for %s", async (authType, optionLabel) => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ ...createdServer, auth_type: authType }); + await setupHttpServerForm(); + await selectAntOption("Authentication", optionLabel); + + const payload = await submitCreate(); + expect(payload.dcr_bridge).toBe(false); + }); + + it("forces dcr_bridge: false when the auth type is switched away after toggling", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ ...createdServer, auth_type: "none" }); + await setupHttpServerForm(); + await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)"); + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + await act(async () => { + fireEvent.click(getDcrToggle()!); + }); + + await selectAntOption("Authentication", "None"); + await waitFor(() => { + expect(getDcrToggle()).not.toBeInTheDocument(); + }); + + const payload = await submitCreate(); + expect(payload.dcr_bridge).toBe(false); + }); + + it("preserves the toggle value when switching between the two client-forwarded modes", async () => { + vi.mocked(networking.createMCPServer).mockResolvedValue({ ...createdServer, auth_type: "oauth_delegate" }); + await setupHttpServerForm(); + await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)"); + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + expect(getDcrToggle()).toHaveAttribute("aria-checked", "true"); + + // The Form.Item is mounted in both client-forwarded modes, so switching between them keeps the + // live toggle value rather than forcing it back to the default or to false. + await selectAntOption("Authentication", "OAuth Delegate (client-supplied upstream token)"); + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + expect(getDcrToggle()).toHaveAttribute("aria-checked", "true"); + + const payload = await submitCreate(); + expect(payload.dcr_bridge).toBe(true); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index eb48fd024742..3afe0679b3bf 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -21,6 +21,7 @@ import { } from "./types"; import OAuthFormFields from "./OAuthFormFields"; import TruePassthroughWarning from "./TruePassthroughWarning"; +import DcrBridgeToggle from "./DcrBridgeToggle"; import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection"; import TokenExchangeFormFields from "./TokenExchangeFormFields"; import MCPServerCostConfig from "./mcp_server_cost_config"; @@ -380,6 +381,7 @@ const CreateMCPServer: React.FC = ({ available_on_public_internet: availableOnPublicInternetRaw, delegate_auth_to_upstream: delegateAuthToUpstreamRaw, oauth_passthrough: oauthPassthroughRaw, + dcr_bridge: dcrBridgeRaw, token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -486,6 +488,11 @@ const CreateMCPServer: React.FC = ({ available_on_public_internet: Boolean(availableOnPublicInternetRaw), delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw), oauth_passthrough: Boolean(oauthPassthroughRaw), + // ``dcr_bridge`` is only meaningful for the client-forwarded token + // modes (true_passthrough / oauth_delegate) and defaults on when the + // toggle is shown; force false for any other auth type so a stale + // ``true`` is never persisted. Mirrors the sibling flags above. + dcr_bridge: isClientForwardedTokenMode(restValues.auth_type) ? Boolean(dcrBridgeRaw ?? true) : false, ...(restValues.auth_type === AUTH_TYPE.OAUTH2 ? { oauth2_flow: @@ -987,6 +994,8 @@ const CreateMCPServer: React.FC = ({ + + { expect(payload.max_concurrent_requests).toBeNull(); }); }); + +describe("MCPServerEdit (dcr_bridge toggle)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockOauth.tokenResponse = null; + }); + + const getDcrToggle = () => document.getElementById("dcr_bridge"); + + function renderEdit(server: Record) { + render( + , + ); + } + + async function saveAndGetPayload() { + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + return payload; + } + + it.each([["true_passthrough"], ["oauth_delegate"]])("renders the toggle for a %s server", async (authType) => { + renderEdit({ auth_type: authType }); + + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + expect(screen.getByText("Gateway-hosted sign-in (DCR bridge)")).toBeInTheDocument(); + }); + + it.each([["oauth2"], ["api_key"], ["none"]])("does not render the toggle for an %s server", async (authType) => { + renderEdit({ auth_type: authType }); + + await waitFor(() => { + expect(screen.getAllByRole("button", { name: "Save Changes" }).length).toBeGreaterThan(0); + }); + expect(screen.queryByText("Gateway-hosted sign-in (DCR bridge)")).not.toBeInTheDocument(); + expect(getDcrToggle()).not.toBeInTheDocument(); + }); + + it("initializes unchecked from a null stored value and saves an explicit false", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + auth_type: "true_passthrough", + }); + renderEdit({ auth_type: "true_passthrough", dcr_bridge: null }); + + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + expect(getDcrToggle()).toHaveAttribute("aria-checked", "false"); + + const payload = await saveAndGetPayload(); + expect(payload.dcr_bridge).toBe(false); + }); + + it("initializes checked from a stored true and saves an explicit true", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + auth_type: "oauth_delegate", + dcr_bridge: true, + }); + renderEdit({ auth_type: "oauth_delegate", dcr_bridge: true }); + + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + expect(getDcrToggle()).toHaveAttribute("aria-checked", "true"); + + const payload = await saveAndGetPayload(); + expect(payload.dcr_bridge).toBe(true); + }); + + it("saves an explicit false after the admin unchecks a stored true", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + auth_type: "true_passthrough", + dcr_bridge: false, + }); + renderEdit({ auth_type: "true_passthrough", dcr_bridge: true }); + + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + await act(async () => { + fireEvent.click(getDcrToggle()!); + }); + expect(getDcrToggle()).toHaveAttribute("aria-checked", "false"); + + const payload = await saveAndGetPayload(); + expect(payload.dcr_bridge).toBe(false); + }); + + it("forces dcr_bridge: false when the auth type is switched away", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + auth_type: "api_key", + }); + renderEdit({ auth_type: "true_passthrough", dcr_bridge: true }); + + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + + await selectAntOption("Authentication", "API Key"); + await waitFor(() => { + expect(getDcrToggle()).not.toBeInTheDocument(); + }); + + // Mirrors the sibling delegate_auth_to_upstream / oauth_passthrough force-false: a stale true is + // never left behind to silently re-activate if the mode is switched back. + const payload = await saveAndGetPayload(); + expect(payload.dcr_bridge).toBe(false); + }); + + it("preserves the toggle value when switching between the two client-forwarded modes", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + auth_type: "oauth_delegate", + dcr_bridge: true, + }); + renderEdit({ auth_type: "true_passthrough", dcr_bridge: true }); + + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + expect(getDcrToggle()).toHaveAttribute("aria-checked", "true"); + + // The Form.Item stays mounted across the two client-forwarded modes, so the live toggle value is + // preserved rather than forced false by the switch. + await selectAntOption("Authentication", "OAuth Delegate (client-supplied upstream token)"); + await waitFor(() => { + expect(getDcrToggle()).toBeInTheDocument(); + }); + expect(getDcrToggle()).toHaveAttribute("aria-checked", "true"); + + const payload = await saveAndGetPayload(); + expect(payload.dcr_bridge).toBe(true); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 7446c96c40e7..67e44e04f2bf 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -23,6 +23,7 @@ import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPPermissionManagement from "./MCPPermissionManagement"; import TruePassthroughWarning from "./TruePassthroughWarning"; +import DcrBridgeToggle from "./DcrBridgeToggle"; import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; @@ -276,6 +277,7 @@ const MCPServerEdit: React.FC = ({ env_vars: initialEnvVars, extra_headers: mcpServer.extra_headers || [], oauth_flow_type: oauth2FlowToFormValue(mcpServer.oauth2_flow), + dcr_bridge: Boolean(mcpServer.dcr_bridge), token_validation_json: mcpServer.token_validation ? JSON.stringify(mcpServer.token_validation, null, 2) : undefined, @@ -627,6 +629,7 @@ const MCPServerEdit: React.FC = ({ available_on_public_internet: availableOnPublicInternetRaw, delegate_auth_to_upstream: delegateAuthToUpstreamRaw, oauth_passthrough: oauthPassthroughRaw, + dcr_bridge: dcrBridgeRaw, token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -837,6 +840,15 @@ const MCPServerEdit: React.FC = ({ ? Boolean(oauthPassthroughRaw ?? mcpServer.oauth_passthrough) : false; })(), + // ``dcr_bridge`` is only meaningful for the client-forwarded token + // modes (true_passthrough / oauth_delegate). The Form.Item is + // conditionally rendered so the value drops out of the form on + // auth_type change; force false for any other configuration to avoid + // persisting a stale ``true`` that would silently re-activate if the + // mode is later switched back. + dcr_bridge: isClientForwardedTokenMode(restValues.auth_type) + ? Boolean(dcrBridgeRaw ?? mcpServer.dcr_bridge) + : false, ...(restValues.auth_type === AUTH_TYPE.OAUTH2 && restValues.oauth_flow_type ? { oauth2_flow: @@ -1032,6 +1044,7 @@ const MCPServerEdit: React.FC = ({ + Date: Fri, 10 Jul 2026 15:47:05 -0700 Subject: [PATCH 2/4] feat(ui): move the dcr_bridge toggle next to the OAuth app fields Render DcrBridgeToggle inside PassthroughAuthorizeSection, after the OAuth client ID/secret fields and just before the Authorize & Fetch Tools button, in both the create and edit flows. Also update the section copy to say a configured OAuth app is saved with the server, using the same wording as the credential lifecycle rework in #32752 so whichever PR lands second rebases cleanly --- .../mcp_tools/PassthroughAuthorizeSection.tsx | 32 +++++++++++-------- .../mcp_tools/create_mcp_server.test.tsx | 19 +++++++++-- .../mcp_tools/create_mcp_server.tsx | 4 +-- .../mcp_tools/mcp_server_edit.test.tsx | 13 ++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 2 -- 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx index af81f2713aec..998fcd2ebf8a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Button, Form, Input } from "antd"; +import DcrBridgeToggle from "./DcrBridgeToggle"; import { isClientForwardedTokenMode } from "./types"; interface PassthroughOAuthFlow { @@ -11,20 +12,23 @@ interface PassthroughOAuthFlow { /** * Browser-only Authorize & Fetch for the client-forwarded token modes - * (true_passthrough / oauth_delegate). LiteLLM never stores upstream - * credentials for these modes, so the token obtained here lives in this - * browser session only: it is forwarded per-server for the tools preview and - * allowlist configuration, and is never written to the server row or the - * per-user credential store. The optional client credentials cover IdPs - * without dynamic client registration (e.g. a pre-registered Slack app) and - * ride the temporary authorize session only. + * (true_passthrough / oauth_delegate). Tokens are never stored: the token + * obtained here lives in this browser session only, forwarded per-server for + * the tools preview and allowlist configuration, and is never written to the + * server row or the per-user credential store. The optional OAuth client + * credentials cover IdPs without dynamic client registration (e.g. a + * pre-registered Slack app); unlike the token they ARE saved onto the server + * as declared config, so internal users' Authorize relays through the org's + * app instead of dead-ending on upstreams that cannot mint clients. */ export default function PassthroughAuthorizeSection({ authType, oauthFlow, + dcrBridgeInitialChecked, }: { authType?: string | null; oauthFlow: PassthroughOAuthFlow; + dcrBridgeInitialChecked?: boolean; }) { if (!isClientForwardedTokenMode(authType)) return null; const authorizeButtonLabels: Record = { @@ -35,14 +39,15 @@ export default function PassthroughAuthorizeSection({ return (

- Callers bring their own upstream token for this auth type, so LiteLLM stores no upstream credentials. To preview - tools and configure the tool allowlist, authorize against the upstream here: the token stays in this browser - session only and is never saved to LiteLLM. + Callers bring their own upstream token for this auth type, so LiteLLM never stores tokens. To preview tools and + configure the tool allowlist, authorize against the upstream here: the token stays in this browser session only + and is never saved to LiteLLM. An OAuth app configured below IS saved with the server, so internal users who + authorize from the Tools page go through it.

OAuth Client ID (optional, not saved)} + label={OAuth Client ID (optional, saved)} name={["credentials", "client_id"]} - extra="Only needed when the upstream does not support dynamic client registration (e.g. a pre-registered Slack app). Used for this browser authorization only." + extra="Set this to make everyone authorize through a specific app; required for upstreams without dynamic client registration (e.g. a pre-registered Slack app)." > OAuth Client Secret (optional, not saved)} + label={OAuth Client Secret (optional, saved)} name={["credentials", "client_secret"]} > +