mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
feat(ui): dcr_bridge toggle for client-forwarded MCP auth modes
This commit is contained in:
parent
34602ff627
commit
47f33bda3f
6 changed files with 378 additions and 0 deletions
|
|
@ -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 (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Gateway-hosted sign-in (DCR bridge)
|
||||
<Tooltip title="Lets OAuth-only clients like Claude Desktop register and sign in through the gateway. Turn off to relay the upstream server's own OAuth metadata instead (for clients pre-registered with the upstream IdP).">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="dcr_bridge"
|
||||
valuePropName="checked"
|
||||
initialValue={initialChecked}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
|
@ -1509,3 +1509,166 @@ 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(<CreateMCPServer {...defaultProps} />);
|
||||
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.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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from "./types";
|
||||
import OAuthFormFields from "./OAuthFormFields";
|
||||
import TruePassthroughWarning from "./TruePassthroughWarning";
|
||||
import DcrBridgeToggle from "./DcrBridgeToggle";
|
||||
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
|
||||
import TokenExchangeFormFields from "./TokenExchangeFormFields";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
|
|
@ -380,6 +381,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
available_on_public_internet: availableOnPublicInternetRaw,
|
||||
delegate_auth_to_upstream: delegateAuthToUpstreamRaw,
|
||||
oauth_passthrough: oauthPassthroughRaw,
|
||||
dcr_bridge: dcrBridgeRaw,
|
||||
token_validation_json: rawTokenValidationJson,
|
||||
...restValues
|
||||
} = values;
|
||||
|
|
@ -486,6 +488,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
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:
|
||||
|
|
@ -987,6 +994,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
|
||||
<TruePassthroughWarning authType={authType} />
|
||||
|
||||
<DcrBridgeToggle authType={authType} initialChecked />
|
||||
|
||||
<PassthroughAuthorizeSection
|
||||
authType={authType}
|
||||
oauthFlow={{
|
||||
|
|
|
|||
|
|
@ -1737,3 +1737,155 @@ describe("MCPServerEdit (max concurrent requests)", () => {
|
|||
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<string, unknown>) {
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{ ...interactiveOAuthServer, ...server }}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
|
|||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
import MCPPermissionManagement from "./MCPPermissionManagement";
|
||||
import TruePassthroughWarning from "./TruePassthroughWarning";
|
||||
import DcrBridgeToggle from "./DcrBridgeToggle";
|
||||
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
|
||||
import MCPToolConfiguration from "./mcp_tool_configuration";
|
||||
import StdioConfiguration from "./StdioConfiguration";
|
||||
|
|
@ -276,6 +277,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
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,
|
||||
|
|
@ -627,6 +629,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
available_on_public_internet: availableOnPublicInternetRaw,
|
||||
delegate_auth_to_upstream: delegateAuthToUpstreamRaw,
|
||||
oauth_passthrough: oauthPassthroughRaw,
|
||||
dcr_bridge: dcrBridgeRaw,
|
||||
token_validation_json: rawTokenValidationJson,
|
||||
...restValues
|
||||
} = values;
|
||||
|
|
@ -837,6 +840,15 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
? 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:
|
||||
|
|
@ -1032,6 +1044,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
</Select>
|
||||
</Form.Item>
|
||||
<TruePassthroughWarning authType={authType} />
|
||||
<DcrBridgeToggle authType={authType} />
|
||||
<PassthroughAuthorizeSection
|
||||
authType={authType}
|
||||
oauthFlow={{
|
||||
|
|
|
|||
|
|
@ -319,6 +319,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;
|
||||
|
||||
/** Stdio-only fields (present when transport === 'stdio') */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue