Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,6 +33,7 @@ interface PassthroughOAuthFlow {
export default function PassthroughAuthorizeSection({
authType,
oauthFlow,
dcrBridgeInitialChecked,
isEditing = false,
savedAuthType,
removeStoredApp = false,
Expand All @@ -40,6 +42,7 @@ export default function PassthroughAuthorizeSection({
}: {
authType?: string | null;
oauthFlow: PassthroughOAuthFlow;
dcrBridgeInitialChecked?: boolean;
isEditing?: boolean;
savedAuthType?: string | null;
removeStoredApp?: boolean;
Expand Down Expand Up @@ -79,7 +82,7 @@ export default function PassthroughAuthorizeSection({
</p>
)}
<Form.Item
label={<span className="text-sm font-medium text-gray-700">OAuth Client ID (optional, saved)</span>}
label={<span className="text-sm font-medium text-gray-700">OAuth Client ID (optional)</span>}
name={["credentials", "client_id"]}
extra={clientIdExtra}
>
Expand All @@ -90,7 +93,7 @@ export default function PassthroughAuthorizeSection({
/>
</Form.Item>
<Form.Item
label={<span className="text-sm font-medium text-gray-700">OAuth Client Secret (optional, saved)</span>}
label={<span className="text-sm font-medium text-gray-700">OAuth Client Secret (optional)</span>}
name={["credentials", "client_secret"]}
>
<Input.Password
Expand All @@ -99,6 +102,7 @@ export default function PassthroughAuthorizeSection({
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<DcrBridgeToggle authType={authType} initialChecked={dcrBridgeInitialChecked} />
{isEditing && onRemoveStoredAppChange && (
<Checkbox checked={removeStoredApp} onChange={(e) => onRemoveStoredAppChange(e.target.checked)}>
<span className="text-sm text-gray-700">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
}));

vi.mock("./mcp_connection_status", () => ({
default: ({ tools }: { tools?: any[] }) => (

Check warning on line 89 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
<div data-testid="mcp-connection-status" data-tool-count={tools?.length ?? 0} />
),
}));
Expand Down Expand Up @@ -205,8 +205,8 @@
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();
},
);

Expand Down Expand Up @@ -242,7 +242,7 @@
});

// Leave auth value empty and submit
vi.mocked(networking.createMCPServer).mockResolvedValue({

Check warning on line 245 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 10 properties passed inline as an argument; assign it to a named variable first
server_id: "new-server-1",
server_name: "Test_Server",
alias: "Test_Server",
Expand Down Expand Up @@ -284,7 +284,7 @@
});

// Leave auth value empty and submit
vi.mocked(networking.createMCPServer).mockResolvedValue({

Check warning on line 287 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 10 properties passed inline as an argument; assign it to a named variable first
server_id: "new-server-1",
server_name: "Test_Server",
alias: "Test_Server",
Expand Down Expand Up @@ -328,7 +328,7 @@
const authInput = screen.getByPlaceholderText("Enter token or secret");
await user.type(authInput, "my-secret-key");

vi.mocked(networking.createMCPServer).mockResolvedValue({

Check warning on line 331 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 10 properties passed inline as an argument; assign it to a named variable first
server_id: "new-server-1",
server_name: "My_Server",
alias: "My_Server",
Expand Down Expand Up @@ -891,7 +891,7 @@

await selectAntOption("Authentication", "None");

vi.mocked(networking.createMCPServer).mockResolvedValue({

Check warning on line 894 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 10 properties passed inline as an argument; assign it to a named variable first
server_id: "new-server-1",
server_name: "No_Auth_Server",
alias: "No_Auth_Server",
Expand Down Expand Up @@ -973,7 +973,7 @@
const limitInput = screen.getByPlaceholderText("e.g. 10");
await user.type(limitInput, "5");

vi.mocked(networking.createMCPServer).mockResolvedValue({

Check warning on line 976 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 10 properties passed inline as an argument; assign it to a named variable first
server_id: "new-server-1",
server_name: "Limited_Server",
alias: "Limited_Server",
Expand Down Expand Up @@ -1026,7 +1026,7 @@
target: { value: "te-client-secret" },
});

vi.mocked(networking.createMCPServer).mockResolvedValue({

Check warning on line 1029 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 10 properties passed inline as an argument; assign it to a named variable first
server_id: "new-server-te",
server_name: "TE_Server",
alias: "TE_Server",
Expand Down Expand Up @@ -1117,7 +1117,7 @@
fireEvent.click(screen.getByRole("button", { name: "Disable all tools" }));
});

vi.mocked(networking.createMCPServer).mockResolvedValue({

Check warning on line 1120 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 10 properties passed inline as an argument; assign it to a named variable first
server_id: "new-server-1",
server_name: "Locked_Down_Server",
alias: "Locked_Down_Server",
Expand Down Expand Up @@ -1262,7 +1262,7 @@
});

it("includes token_validation in payload when token_validation_json is filled with valid JSON", async () => {
vi.mocked(networking.createMCPServer).mockResolvedValue({

Check warning on line 1265 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 10 properties passed inline as an argument; assign it to a named variable first
server_id: "new-server-oauth",
server_name: "OAuth_Server",
alias: "OAuth_Server",
Expand Down Expand Up @@ -1329,7 +1329,7 @@

await waitFor(() => expect(oauthHook.reset).toHaveBeenCalled());

vi.mocked(networking.createMCPServer).mockResolvedValue({

Check warning on line 1332 in ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Object literal with 10 properties passed inline as an argument; assign it to a named variable first
server_id: "new-server-oauth",
server_name: "Url_Change_Server",
alias: "Url_Change_Server",
Expand Down Expand Up @@ -1940,3 +1940,181 @@
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("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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,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;
Expand Down Expand Up @@ -545,6 +546,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:
Expand Down Expand Up @@ -1084,6 +1090,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({

<PassthroughAuthorizeSection
authType={authType}
dcrBridgeInitialChecked
oauthFlow={{
startOAuthFlow,
status: oauthStatus,
Expand Down
Loading
Loading