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 && }