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;