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/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx
index 4cd551e4d106..0ed4ee555d11 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, Checkbox, Form, Input } from "antd";
+import DcrBridgeToggle from "./DcrBridgeToggle";
import { credentialAuthClass, isClientForwardedTokenMode } from "./types";
interface PassthroughOAuthFlow {
@@ -32,6 +33,7 @@ interface PassthroughOAuthFlow {
export default function PassthroughAuthorizeSection({
authType,
oauthFlow,
+ dcrBridgeInitialChecked,
isEditing = false,
savedAuthType,
removeStoredApp = false,
@@ -40,6 +42,7 @@ export default function PassthroughAuthorizeSection({
}: {
authType?: string | null;
oauthFlow: PassthroughOAuthFlow;
+ dcrBridgeInitialChecked?: boolean;
isEditing?: boolean;
savedAuthType?: string | null;
removeStoredApp?: boolean;
@@ -79,7 +82,7 @@ export default function PassthroughAuthorizeSection({
)}
OAuth Client ID (optional, saved)}
+ label={OAuth Client ID (optional)}
name={["credentials", "client_id"]}
extra={clientIdExtra}
>
@@ -90,7 +93,7 @@ export default function PassthroughAuthorizeSection({
/>
OAuth Client Secret (optional, saved)}
+ label={OAuth Client Secret (optional)}
name={["credentials", "client_secret"]}
>
+
{isEditing && onRemoveStoredAppChange && (
onRemoveStoredAppChange(e.target.checked)}>
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 bc33ed79e495..32fe439a3168 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
@@ -205,8 +205,8 @@ describe("CreateMCPServer", () => {
await waitFor(() => {
expect(screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).toBeInTheDocument();
});
- expect(screen.getByText("OAuth Client ID (optional, saved)")).toBeInTheDocument();
- expect(screen.getByText("OAuth Client Secret (optional, saved)")).toBeInTheDocument();
+ expect(screen.getByText("OAuth Client ID (optional)")).toBeInTheDocument();
+ expect(screen.getByText("OAuth Client Secret (optional)")).toBeInTheDocument();
},
);
@@ -1940,3 +1940,181 @@ 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("renders the toggle between the OAuth client fields and the Authorize button", async () => {
+ await setupHttpServerForm();
+
+ await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)");
+
+ await waitFor(() => {
+ expect(getDcrToggle()).toBeInTheDocument();
+ });
+ const toggle = getDcrToggle() as HTMLElement;
+ const secretInput = screen.getByPlaceholderText("Leave blank for public clients / PKCE");
+ const authorizeButton = screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" });
+ expect(secretInput.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(toggle.compareDocumentPosition(authorizeButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ 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 25c712e5b01c..b9a6f5d229d8 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
@@ -439,6 +439,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;
@@ -545,6 +546,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:
@@ -1084,6 +1090,7 @@ 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("renders the toggle between the OAuth client fields and the Authorize button", async () => {
+ renderEdit({ auth_type: "true_passthrough" });
+
+ await waitFor(() => {
+ expect(getDcrToggle()).toBeInTheDocument();
+ });
+ const toggle = getDcrToggle() as HTMLElement;
+ const secretInput = screen.getByPlaceholderText("Leave blank to keep the currently saved secret (if any)");
+ const authorizeButton = screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" });
+ expect(secretInput.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(toggle.compareDocumentPosition(authorizeButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ 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 4ff9723f65ad..6709eb02c656 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
@@ -290,6 +290,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,
@@ -683,6 +684,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;
@@ -893,6 +895,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:
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
index 0aceff87143e..04766aad7b47 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
@@ -367,6 +367,7 @@ export interface MCPServer {
available_on_public_internet?: boolean;
delegate_auth_to_upstream?: boolean;
oauth_passthrough?: boolean;
+ dcr_bridge?: boolean | null;
max_concurrent_requests?: number | null;
/** Redacted to null in server responses; present when constructing a server locally. */
credentials?: Record | null;