From e2b3728c05c9a588346436c256ef1fd344cabff9 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 11 Jun 2026 09:37:16 -0700 Subject: [PATCH] feat(ui/mcp): place OAuth upstream-delegation toggle in the PKCE flow, add passthrough notice Render the "Delegate auth to upstream (PKCE passthrough)" toggle inside the OAuth fields, right after the OAuth Flow Type selector and only for the Interactive (PKCE) flow, instead of leaving it floating at the bottom of the form or buried in the Permission Management / Access Control section. It now sits next to the OAuth config it controls in both the create and edit MCP server forms. When the toggle is on, show a short notice explaining that clients authenticate directly with the upstream MCP server, that LiteLLM won't enforce its own API key/SSO on this route, and that it won't store user credentials, so the endpoint is reachable without a LiteLLM login. The existing internal-network warning still shows when passthrough is on for an internal-only server. The toggle, the passthrough notice, and the internal-network warning live in a single prop-less DelegateAuthToUpstreamField component that reads everything from the form, shared by both forms. Because the toggle is only mounted for the Interactive OAuth flow, the safety that forces the flag back to false for M2M or non-oauth2 auth happens at submit time in both forms; switching transport to stdio also clears it in handleTransportChange so the form store stays consistent. The separate none-auth oauth_passthrough toggle in the Permission Management section is unrelated and left untouched. --- .../DelegateAuthToUpstreamField.test.tsx | 59 +++++++++++++++++ .../mcp_tools/DelegateAuthToUpstreamField.tsx | 51 +++++++++++++++ .../MCPPermissionManagement.test.tsx | 6 +- .../mcp_tools/MCPPermissionManagement.tsx | 64 ++----------------- .../components/mcp_tools/OAuthFormFields.tsx | 2 + .../mcp_tools/create_mcp_server.tsx | 17 ++++- .../mcp_tools/mcp_server_edit.test.tsx | 54 ++++++++++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 22 ++++--- 8 files changed, 199 insertions(+), 76 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/DelegateAuthToUpstreamField.test.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/DelegateAuthToUpstreamField.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/DelegateAuthToUpstreamField.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/DelegateAuthToUpstreamField.test.tsx new file mode 100644 index 00000000000..83f05307db0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/DelegateAuthToUpstreamField.test.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { Form, Input } from "antd"; + +import DelegateAuthToUpstreamField from "./DelegateAuthToUpstreamField"; + +const PASSTHROUGH_NOTICE = /reachable without a LiteLLM login/i; +const INTERNAL_WARNING = /Internal server with upstream OAuth delegation/i; + +const Harness: React.FC<{ initialValues?: Record }> = ({ initialValues }) => { + const [form] = Form.useForm(); + return ( +
+ + + + ); +}; + +const renderField = (initialValues: Record = {}) => render(); + +describe("DelegateAuthToUpstreamField", () => { + it("renders the toggle off with no passthrough notice by default", () => { + renderField(); + expect(screen.getByText("Delegate auth to upstream (PKCE passthrough)")).toBeInTheDocument(); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + expect(screen.queryByText(PASSTHROUGH_NOTICE)).not.toBeInTheDocument(); + }); + + it("shows the passthrough notice once delegation is enabled", async () => { + const user = userEvent.setup(); + renderField({ available_on_public_internet: true }); + + expect(screen.queryByText(PASSTHROUGH_NOTICE)).not.toBeInTheDocument(); + await user.click(screen.getByRole("switch")); + + expect(screen.getByText(PASSTHROUGH_NOTICE)).toBeInTheDocument(); + expect(screen.queryByText(INTERNAL_WARNING)).not.toBeInTheDocument(); + }); + + it("warns when delegation is on for an internal-only server", async () => { + const user = userEvent.setup(); + renderField({ available_on_public_internet: false }); + await user.click(screen.getByRole("switch")); + + expect(screen.getByText(PASSTHROUGH_NOTICE)).toBeInTheDocument(); + expect(screen.getByText(INTERNAL_WARNING)).toBeInTheDocument(); + }); + + it("reflects a saved delegate_auth_to_upstream value when editing", () => { + renderField({ delegate_auth_to_upstream: true, available_on_public_internet: true }); + expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true"); + expect(screen.getByText(PASSTHROUGH_NOTICE)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/DelegateAuthToUpstreamField.tsx b/ui/litellm-dashboard/src/components/mcp_tools/DelegateAuthToUpstreamField.tsx new file mode 100644 index 00000000000..8f0014ca374 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/DelegateAuthToUpstreamField.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Alert, Form, Switch, Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; + +const DelegateAuthToUpstreamField: React.FC = () => { + const form = Form.useFormInstance(); + const delegateAuth = Form.useWatch("delegate_auth_to_upstream", form) === true; + const internalOnly = Form.useWatch("available_on_public_internet", form) === false; + + return ( +
+
+
+ + Delegate auth to upstream (PKCE passthrough) + + + + +

+ Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server. +

+
+ + + +
+ + {delegateAuth && ( +
+ + + Clients authenticate directly with the upstream MCP server. LiteLLM won't require its own API key/SSO + on this route and won't store user credentials, so this endpoint is reachable without a LiteLLM login. + +
+ )} + + {delegateAuth && internalOnly && ( + + )} +
+ ); +}; + +export default DelegateAuthToUpstreamField; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx index a53681f6cd8..925a99c5c8a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx @@ -73,11 +73,11 @@ describe("MCPPermissionManagement", () => { ); }; - it("shows only the oauth2 PKCE-delegation toggle for oauth2 servers", async () => { + it("does not render the oauth2 PKCE-delegation toggle here (it lives next to the auth fields now)", async () => { renderWithInitialValues({ allow_all_keys: false, auth_type: "oauth2" }); await expandPanel(); - expect(screen.getByText("Delegate auth to upstream (PKCE passthrough)")).toBeInTheDocument(); - // The non-oauth2 pass-through toggle must NOT appear for oauth2 servers. + expect(screen.queryByText("Delegate auth to upstream (PKCE passthrough)")).not.toBeInTheDocument(); + // The none-auth pass-through toggle must NOT appear for oauth2 servers either. expect(screen.queryByText("OAuth pass-through")).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx index 27cbdf2ea34..6a0ccdb45ab 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react"; -import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd"; +import { Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd"; import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; import { MCPServer, AUTH_TYPE } from "./types"; const { Panel } = Collapse; @@ -24,24 +24,15 @@ const MCPPermissionManagement: React.FC = ({ }) => { const form = Form.useFormInstance(); const watchedAuthType = Form.useWatch("auth_type", form); - const isOAuth2 = watchedAuthType === AUTH_TYPE.OAUTH2; const isNoneAuth = watchedAuthType === AUTH_TYPE.NONE || watchedAuthType == null; const watchedExtraHeaders = Form.useWatch("extra_headers", form); const hasAuthorizationHeader = Array.isArray(watchedExtraHeaders) && watchedExtraHeaders.some((h) => typeof h === "string" && h.toLowerCase() === "authorization"); - // Two distinct, independent opt-ins: - // - delegate_auth_to_upstream: oauth2 servers only (PKCE passthrough — - // bypass LiteLLM admission). - // - oauth_passthrough: auth_type=none + Authorization in extra_headers - // (OAuth pass-through: proxy upstream oauth-protected-resource, emit 401 - // challenges, propagate upstream 401/403). - // Kept as separate flags so neither silently implies the other and existing - // oauth2 servers can't regress into pass-through behavior. + // oauth_passthrough only applies to auth_type=none servers that forward an + // Authorization header upstream (proxy upstream oauth-protected-resource, emit + // 401 challenges, propagate upstream 401/403). const canEnableOAuthPassthrough = isNoneAuth && hasAuthorizationHeader; - const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form); - const watchedPublicInternet = Form.useWatch("available_on_public_internet", form); - const showInternalDelegatePkceWarning = isOAuth2 && watchedDelegateAuth === true && watchedPublicInternet === false; // Set initial values when mcpServer changes useEffect(() => { @@ -70,29 +61,16 @@ const MCPPermissionManagement: React.FC = ({ if (typeof mcpServer.available_on_public_internet === "boolean") { form.setFieldValue("available_on_public_internet", mcpServer.available_on_public_internet); } - if (typeof mcpServer.delegate_auth_to_upstream === "boolean") { - form.setFieldValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream); - } if (typeof mcpServer.oauth_passthrough === "boolean") { form.setFieldValue("oauth_passthrough", mcpServer.oauth_passthrough); } } else { form.setFieldValue("allow_all_keys", false); form.setFieldValue("available_on_public_internet", true); - form.setFieldValue("delegate_auth_to_upstream", false); form.setFieldValue("oauth_passthrough", false); } }, [mcpServer, form]); - // delegate_auth_to_upstream is only honored server-side for oauth2 servers. - // Force it back to false whenever the user switches away from oauth2 so a - // stale toggle value doesn't get persisted unexpectedly. - useEffect(() => { - if (!isOAuth2) { - form.setFieldValue("delegate_auth_to_upstream", false); - } - }, [isOAuth2, form]); - // oauth_passthrough is only honored for auth_type=none servers that forward // Authorization upstream. Force it back to false otherwise. useEffect(() => { @@ -164,30 +142,6 @@ const MCPPermissionManagement: React.FC = ({ - {isOAuth2 && ( -
-
- - Delegate auth to upstream (PKCE passthrough) - - - - -

- Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server. -

-
- - - -
- )} - {canEnableOAuthPassthrough && (
@@ -213,16 +167,6 @@ const MCPPermissionManagement: React.FC = ({
)} - {showInternalDelegatePkceWarning && ( - - )} - diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx index 27d90d5ae1a..1fe25e20dd9 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx @@ -3,6 +3,7 @@ import { Form, Input, InputNumber, Select, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { OAUTH_FLOW } from "./types"; +import DelegateAuthToUpstreamField from "./DelegateAuthToUpstreamField"; interface OAuthFlowStatus { startOAuthFlow: () => void; @@ -115,6 +116,7 @@ const OAuthFormFields: React.FC = ({ ) : ( <> + 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 ddcc9f65d38..aeb3f439f86 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 @@ -315,6 +315,8 @@ const CreateMCPServer: React.FC = ({ // Transform access groups into objects with name property const accessGroups = restValues.mcp_access_groups; + const isInteractiveOAuth = restValues.auth_type === AUTH_TYPE.OAUTH2 && values.oauth_flow_type !== OAUTH_FLOW.M2M; + const staticHeaders = reduceStaticHeaders(staticHeadersList); const envVars = normalizeEnvVars(envVarsList); @@ -414,7 +416,10 @@ const CreateMCPServer: React.FC = ({ tool_name_to_description: toolNameToDescription, allow_all_keys: Boolean(allowAllKeysRaw), available_on_public_internet: Boolean(availableOnPublicInternetRaw), - delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw), + // delegate_auth_to_upstream (PKCE passthrough) is only honored for the + // Interactive OAuth flow; force false otherwise so a stale toggle value + // can't persist against M2M or non-oauth2 auth. + delegate_auth_to_upstream: isInteractiveOAuth && Boolean(delegateAuthToUpstreamRaw), oauth_passthrough: Boolean(oauthPassthroughRaw), static_headers: staticHeaders, env_vars: envVars, @@ -443,7 +448,7 @@ const CreateMCPServer: React.FC = ({ const oauthMode = getMcpOAuthMode({ auth_type: restValues.auth_type, oauth2_flow: values.oauth_flow_type === OAUTH_FLOW.M2M ? MCP_OAUTH2_FLOW_M2M : null, - delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw), + delegate_auth_to_upstream: isInteractiveOAuth && Boolean(delegateAuthToUpstreamRaw), }); if (oauthMode === "obo") { const scope = oauthTokenResponse.scope; @@ -506,7 +511,13 @@ const CreateMCPServer: React.FC = ({ setTransportType(value); // Clear fields that are not relevant for the selected transport if (value === "stdio") { - form.setFieldsValue({ url: undefined, spec_path: undefined, auth_type: undefined, credentials: undefined }); + form.setFieldsValue({ + url: undefined, + spec_path: undefined, + auth_type: undefined, + credentials: undefined, + delegate_auth_to_upstream: false, + }); } else if (value === TRANSPORT.OPENAPI) { form.setFieldsValue({ url: undefined, command: undefined, args: undefined, env: undefined }); } else { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index d7c1241b044..c86a3ae6df5 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -262,6 +262,60 @@ describe("MCPServerEdit (delegate auth)", () => { expect(payload.delegate_auth_to_upstream).toBe(false); }); + it("clears the delegate auth flag when switching an oauth2 server to stdio transport", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + transport: "stdio", + auth_type: "none", + command: "npx", + }); + + render( + , + ); + + // Sanity: the delegate toggle is shown for the interactive-OAuth server. + expect(screen.getByText("Delegate auth to upstream (PKCE passthrough)")).toBeInTheDocument(); + + // Switch transport to stdio: the OAuth section (and toggle) unmounts. + const transportSelect = screen.getByLabelText("Transport Type"); + await act(async () => { + fireEvent.mouseDown(transportSelect); + }); + await act(async () => { + fireEvent.click(screen.getByText("Standard Input/Output (stdio)")); + }); + + expect(screen.queryByText("Delegate auth to upstream (PKCE passthrough)")).not.toBeInTheDocument(); + + const commandInput = screen.getByLabelText("Command"); + await act(async () => { + fireEvent.change(commandInput, { target: { value: "npx" } }); + }); + + 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]; + expect(payload.transport).toBe("stdio"); + expect(payload.delegate_auth_to_upstream).toBe(false); + }); + it("does not enable oauth_passthrough for an oauth2 server", async () => { vi.mocked(networking.updateMCPServer).mockResolvedValue({ ...interactiveOAuthServer, 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 b992313d4a8..dcc090089be 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 @@ -16,6 +16,7 @@ import { getToken, isTokenValid, setToken } from "@/utils/mcpTokenStore"; import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPPermissionManagement from "./MCPPermissionManagement"; +import DelegateAuthToUpstreamField from "./DelegateAuthToUpstreamField"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; import MCPLogoSelector from "./MCPLogoSelector"; @@ -426,6 +427,7 @@ const MCPServerEdit: React.FC = ({ authorization_url: undefined, token_url: undefined, registration_url: undefined, + delegate_auth_to_upstream: false, }); } else if (value === TRANSPORT.OPENAPI) { form.setFieldsValue({ @@ -641,16 +643,15 @@ const MCPServerEdit: React.FC = ({ env_vars: envVars, allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys), available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet), - // ``delegate_auth_to_upstream`` is only honored server-side for - // ``auth_type=oauth2`` (PKCE passthrough). 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 - // configuration is later switched back. - delegate_auth_to_upstream: (() => { - const isOauth2 = restValues.auth_type === AUTH_TYPE.OAUTH2; - return isOauth2 ? Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream) : false; - })(), + // ``delegate_auth_to_upstream`` (PKCE passthrough) is only honored for the + // Interactive OAuth flow. The toggle is conditionally rendered, so force + // false for M2M or non-oauth2 auth to avoid persisting a stale ``true`` + // that would silently re-activate if the configuration is later switched + // back. + delegate_auth_to_upstream: + isOAuthAuthType && !isM2MFlow + ? Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream) + : false, // ``oauth_passthrough`` is the dedicated, non-oauth2 opt-in. It is only // honored for ``auth_type=none`` servers that forward ``Authorization`` // upstream. Kept separate from ``delegate_auth_to_upstream`` so enabling @@ -913,6 +914,7 @@ const MCPServerEdit: React.FC = ({ {!isStdioTransport && isOAuthAuthType && ( <> + {!isM2MFlow && }