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
Expand Up @@ -7,7 +7,7 @@
// ── helpers ──────────────────────────────────────────────────────────────────

/** Minimal Ant Form wrapper so Form.Item registers correctly. */
const WithForm: React.FC<{ children: React.ReactNode; onFinish?: (values: any) => void }> = ({

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

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
children,
onFinish,
}) => {
Expand Down Expand Up @@ -91,6 +91,47 @@
});
});

describe("token endpoint auth method selector", () => {
it("renders directly below the Token URL field in interactive mode", () => {
render(
<WithForm>
<OAuthFormFields isM2M={false} />
</WithForm>,
);
const tokenUrlLabel = screen.getByText("Token URL (optional)");
const authMethodLabel = screen.getByText("Token Endpoint Auth Method (optional)");
expect(tokenUrlLabel.compareDocumentPosition(authMethodLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});

it("renders directly below the Token URL field in M2M mode", () => {
render(
<WithForm>
<OAuthFormFields isM2M={true} />
</WithForm>,
);
const tokenUrlLabel = screen.getByText("Token URL");
const authMethodLabel = screen.getByText("Token Endpoint Auth Method (optional)");
expect(tokenUrlLabel.compareDocumentPosition(authMethodLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});

it("offers client_secret_basic and client_secret_post options", async () => {
render(
<WithForm>
<OAuthFormFields isM2M={false} />
</WithForm>,
);
const authMethodLabel = screen.getByText("Token Endpoint Auth Method (optional)");
const selector = authMethodLabel.closest(".ant-form-item")!.querySelector(".ant-select-selector")!;
await act(async () => {
fireEvent.mouseDown(selector);
});
await waitFor(() => {
expect(screen.getByText("Client Secret Basic")).toBeInTheDocument();
expect(screen.getByText("Client Secret Post")).toBeInTheDocument();
});
});
});

// ── token_validation_json inline JSON validator ──────────────────────────────

describe("token_validation_json validation", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { OAUTH_FLOW } from "./types";
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";

interface OAuthFlowStatus {
startOAuthFlow: () => void;
Expand Down Expand Up @@ -101,6 +102,7 @@
>
<TextInput placeholder="https://auth.example.com/oauth/token" className={fieldClassName} />
</Form.Item>
<TokenEndpointAuthMethodField isEditing={isEditing} />
<Form.Item
label={
<FieldLabel
Expand Down Expand Up @@ -182,6 +184,7 @@
>
<TextInput placeholder="https://example.com/oauth/token" className={fieldClassName} />
</Form.Item>
<TokenEndpointAuthMethodField isEditing={isEditing} />
<Form.Item
label={
<FieldLabel
Expand All @@ -203,7 +206,7 @@
name="token_validation_json"
rules={[
{
validator: (_: any, value: string) => {

Check warning on line 209 in ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
if (!value || value.trim() === "") return Promise.resolve();
try {
JSON.parse(value);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";
import { Form, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";

const TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS = [
{ value: "client_secret_basic", label: "Client Secret Basic" },
{ value: "client_secret_post", label: "Client Secret Post" },
];

interface TokenEndpointAuthMethodFieldProps {
isEditing?: boolean;
}

const TokenEndpointAuthMethodField: React.FC<TokenEndpointAuthMethodFieldProps> = ({ isEditing = false }) => (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Token Endpoint Auth Method (optional)
<Tooltip title="How the proxy authenticates to the upstream OAuth token endpoint. Client Secret Basic sends the client credentials in an HTTP Basic Authorization header; leave blank to use the default, Client Secret Post, which sends them in the request body.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "token_endpoint_auth_method"]}
>
<Select
allowClear
placeholder={
isEditing ? "Leave blank to keep existing (default Client Secret Post)" : "Default (Client Secret Post)"
}
className="rounded-lg"
size="large"
options={TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS}
/>
</Form.Item>
);

export default TokenEndpointAuthMethodField;
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import * as networking from "../networking";
import { setToken } from "@/utils/mcpTokenStore";
import CreateMCPServer from "./create_mcp_server";
import { selectAntOption } from "./testUtils";

vi.mock("../networking", () => ({
createMCPServer: vi.fn(),
Expand Down Expand Up @@ -67,7 +68,7 @@
}));

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

Check warning on line 71 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 All @@ -88,45 +89,6 @@
/** Helper: get the server_name input by its Ant Form id */
const getServerNameInput = () => document.getElementById("server_name") as HTMLInputElement;

/** Helper: select a dropdown option by opening a select near a label and clicking an option */
async function selectAntOption(labelText: string, optionText: string) {
const label = screen.getByText(labelText);
// First try to find a .ant-form-item ancestor (standard form fields)
let select: Element | null = null;
const formItem = label.closest(".ant-form-item");
if (formItem) {
select = formItem.querySelector(".ant-select");
}
// If not found, try .ant-collapse-content ancestor (auth type is inside a Collapse panel)
if (!select) {
const collapseContent = label.closest(".ant-collapse-item");
if (collapseContent) {
select = collapseContent.querySelector(".ant-select");
}
}
// Fallback: look for a sibling or nearby select
if (!select) {
const parent = label.closest("div");
select = parent?.querySelector(".ant-select") ?? null;
}
act(() => {
fireEvent.mouseDown(select!.querySelector(".ant-select-selector")!);
});

await waitFor(() => {
const options = document.querySelectorAll(".ant-select-item-option");
expect(options.length).toBeGreaterThan(0);
});

const option = Array.from(document.querySelectorAll(".ant-select-item-option")).find((el) =>
el.textContent?.includes(optionText),
);
expect(option).toBeTruthy();
act(() => {
fireEvent.click(option!);
});
}

describe("CreateMCPServer", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -534,6 +496,84 @@
expect(payload.token_validation).toBeUndefined();
});

it("includes credentials.token_endpoint_auth_method in payload when client_secret_basic is selected", async () => {
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-oauth",
server_name: "OAuth_Server",
alias: "OAuth_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "oauth2",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});

await setupOAuthInteractive();

const nameInput = document.getElementById("server_name") as HTMLInputElement;
await act(async () => {
fireEvent.change(nameInput, { target: { value: "OAuth_Server" } });
});
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await act(async () => {
fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } });
});

await selectAntOption("Token Endpoint Auth Method (optional)", "Client Secret Basic");

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];
expect(payload.credentials?.token_endpoint_auth_method).toBe("client_secret_basic");
});

it("omits token_endpoint_auth_method from credentials when left blank", async () => {
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-oauth",
server_name: "OAuth_Server",
alias: "OAuth_Server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "oauth2",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});

await setupOAuthInteractive();

const nameInput = document.getElementById("server_name") as HTMLInputElement;
await act(async () => {
fireEvent.change(nameInput, { target: { value: "OAuth_Server" } });
});
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await act(async () => {
fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } });
});

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];
expect(payload.credentials?.token_endpoint_auth_method).toBeUndefined();
});

it("persists access + refresh token to the DB on submit for OBO mode", async () => {
// "Authorize & Fetch" produced a token before submit.
oauthHook.tokenResponse = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import MCPServerEdit from "./mcp_server_edit";
import * as networking from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { selectAntOption } from "./testUtils";

vi.mock("../networking", () => ({
updateMCPServer: vi.fn(),
Expand All @@ -18,7 +19,7 @@
},
}));

const mockOauth: { tokenResponse: any } = { tokenResponse: null };

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

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
vi.mock("@/hooks/useMcpOAuthFlow", () => ({
useMcpOAuthFlow: () => ({
startOAuthFlow: vi.fn(),
Expand All @@ -45,7 +46,7 @@
onToolAllowlistInteraction,
onToolNameToDisplayNameChange,
onToolNameToDescriptionChange,
}: any) => (

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

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
<div
data-testid="mcp-tool-config"
data-existing-allowed-tools={JSON.stringify(existingAllowedTools)}
Expand Down Expand Up @@ -78,9 +79,9 @@
const mockIsTokenValid = vi.fn();
const mockSetToken = vi.fn();
vi.mock("@/utils/mcpTokenStore", () => ({
getToken: (...args: any[]) => mockGetToken(...args),

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

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
isTokenValid: (...args: any[]) => mockIsTokenValid(...args),

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

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
setToken: (...args: any[]) => mockSetToken(...args),

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

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
}));

// ── fixtures ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -506,6 +507,68 @@
expect(payload.token_validation).toEqual({ organization: "my-org" });
});

it("includes credentials.token_endpoint_auth_method in update payload when client_secret_basic is selected", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer);

render(
<MCPServerEdit
mcpServer={interactiveOAuthServer}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);

await waitFor(() => {
expect(screen.getByText("Token Endpoint Auth Method (optional)")).toBeInTheDocument();
});

await selectAntOption("Token Endpoint Auth Method (optional)", "Client Secret Basic");

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.credentials?.token_endpoint_auth_method).toBe("client_secret_basic");
});

it("omits token_endpoint_auth_method from the update payload when the selector is left blank", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer);

render(
<MCPServerEdit
mcpServer={interactiveOAuthServer}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);

await waitFor(() => {
expect(screen.getByText("Token Endpoint Auth Method (optional)")).toBeInTheDocument();
});

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.credentials?.token_endpoint_auth_method).toBeUndefined();
});

it("does not include token_validation in payload when field is empty and server had none", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer);

Comment thread
greptile-apps[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -788,7 +851,7 @@
};

// Mount before the server is loaded (mirrors landing on the page mid OAuth return).
const { rerender } = render(<MCPServerEdit mcpServer={{ server_id: "" } as any} {...props} />);

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

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
expect(screen.queryByDisplayValue("https://example.com/mcp")).not.toBeInTheDocument();

// Server data arrives; the form must repopulate rather than staying blank.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import StdioConfiguration from "./StdioConfiguration";
import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
import { validateMCPServerUrl, validateMCPServerName, normalizeEnvVars } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
Expand All @@ -45,7 +46,7 @@
onCancel,
onSuccess,
availableAccessGroups,
}) => {

Check warning on line 49 in ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Arrow function has a complexity of 43. Maximum allowed is 20
const [form] = Form.useForm();
const [costConfig, setCostConfig] = useState<MCPServerCostInfo>({});
const [tools, setTools] = useState<any[]>([]);
Expand Down Expand Up @@ -996,6 +997,7 @@
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<TokenEndpointAuthMethodField isEditing />
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Expand Down
27 changes: 27 additions & 0 deletions ui/litellm-dashboard/src/components/mcp_tools/testUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { expect } from "vitest";

export async function selectAntOption(labelText: string, optionText: string) {
const label = screen.getByText(labelText);
const select =
label.closest(".ant-form-item")?.querySelector(".ant-select") ??
label.closest(".ant-collapse-item")?.querySelector(".ant-select") ??
label.closest("div")?.querySelector(".ant-select") ??
null;

act(() => {
fireEvent.mouseDown(select!.querySelector(".ant-select-selector")!);
});

await waitFor(() => {
expect(document.querySelectorAll(".ant-select-item-option").length).toBeGreaterThan(0);
});

const option = Array.from(document.querySelectorAll(".ant-select-item-option")).find((el) =>
el.textContent?.includes(optionText),
);
expect(option).toBeTruthy();
act(() => {
fireEvent.click(option!);
});
}
Loading