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.
This commit is contained in:
Tin Chi Lo 2026-06-11 09:37:16 -07:00
parent 49ca04d8c3
commit e2b3728c05
8 changed files with 199 additions and 76 deletions

View file

@ -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<string, any> }> = ({ initialValues }) => {
const [form] = Form.useForm();
return (
<Form form={form} initialValues={initialValues}>
<Form.Item name="available_on_public_internet" hidden>
<Input />
</Form.Item>
<DelegateAuthToUpstreamField />
</Form>
);
};
const renderField = (initialValues: Record<string, any> = {}) => render(<Harness initialValues={initialValues} />);
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();
});
});

View file

@ -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 (
<div className="space-y-3 mb-4">
<div className="flex items-start justify-between gap-4">
<div>
<span className="text-sm font-medium text-gray-700 flex items-center">
Delegate auth to upstream (PKCE passthrough)
<Tooltip title="When on, LiteLLM skips its own API key/SSO check for this server and lets the client complete PKCE directly with the upstream MCP server. Only honored for the Interactive (PKCE) flow. No spend tracking or per-key rate limiting will run on this route.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
<p className="text-sm text-gray-600 mt-1">
Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server.
</p>
</div>
<Form.Item name="delegate_auth_to_upstream" valuePropName="checked" className="mb-0">
<Switch />
</Form.Item>
</div>
{delegateAuth && (
<div className="p-3 bg-blue-50 rounded-lg text-sm text-blue-700 flex items-start gap-2">
<InfoCircleOutlined className="mt-0.5 flex-shrink-0" />
<span>
Clients authenticate directly with the upstream MCP server. LiteLLM won&apos;t require its own API key/SSO
on this route and won&apos;t store user credentials, so this endpoint is reachable without a LiteLLM login.
</span>
</div>
)}
{delegateAuth && internalOnly && (
<Alert
type="warning"
showIcon
message="Internal server with upstream OAuth delegation"
description="This MCP server is configured as internal-only but delegates auth to upstream. Anonymous users will be able to reach the upstream OAuth2 /authorize flow without a LiteLLM session. Ensure your upstream provider and network enforce access controls."
/>
)}
</div>
);
};
export default DelegateAuthToUpstreamField;

View file

@ -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();
});

View file

@ -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<MCPPermissionManagementProps> = ({
}) => {
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<MCPPermissionManagementProps> = ({
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<MCPPermissionManagementProps> = ({
</Form.Item>
</div>
{isOAuth2 && (
<div className="flex items-start justify-between gap-4">
<div>
<span className="text-sm font-medium text-gray-700 flex items-center">
Delegate auth to upstream (PKCE passthrough)
<Tooltip title="When on, LiteLLM skips its own API key/SSO check for this server and lets the client complete PKCE directly with the upstream MCP server. Only honored when Auth Type is oauth2. No spend tracking or per-key rate limiting will run on this route.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
<p className="text-sm text-gray-600 mt-1">
Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server.
</p>
</div>
<Form.Item
name="delegate_auth_to_upstream"
valuePropName="checked"
initialValue={mcpServer?.delegate_auth_to_upstream ?? false}
className="mb-0"
>
<Switch />
</Form.Item>
</div>
)}
{canEnableOAuthPassthrough && (
<div className="flex items-start justify-between gap-4">
<div>
@ -213,16 +167,6 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
</div>
)}
{showInternalDelegatePkceWarning && (
<Alert
type="warning"
showIcon
className="mb-2"
message="Internal server with upstream OAuth delegation"
description="This MCP server is configured as internal-only but delegates auth to upstream. Anonymous users will be able to reach the upstream OAuth2 /authorize flow without a LiteLLM session. Ensure your upstream provider and network enforce access controls."
/>
)}
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">

View file

@ -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<OAuthFormFieldsProps> = ({
</>
) : (
<>
<DelegateAuthToUpstreamField />
<Form.Item
label={
<span className="flex items-center justify-between w-full">

View file

@ -315,6 +315,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
// 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<CreateMCPServerProps> = ({
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<CreateMCPServerProps> = ({
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<CreateMCPServerProps> = ({
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 {

View file

@ -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(
<MCPServerEdit
mcpServer={{
...interactiveOAuthServer,
delegate_auth_to_upstream: true,
}}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);
// 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,

View file

@ -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<MCPServerEditProps> = ({
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<MCPServerEditProps> = ({
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<MCPServerEditProps> = ({
{!isStdioTransport && isOAuthAuthType && (
<>
{!isM2MFlow && <DelegateAuthToUpstreamField />}
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">