feat(ui): add token endpoint auth method selector to MCP OAuth forms (#31739)

PR #31635 added a per-server token_endpoint_auth_method (client_secret_basic
or client_secret_post) for upstream OAuth token endpoints, but it could only be
set by editing the stored credentials JSON. This surfaces it in the dashboard as
an optional selector directly under the Token URL field, in both the create form
(OAuthFormFields, M2M and interactive flows) and the edit form. The field binds
to credentials.token_endpoint_auth_method, which the backend already reads; the
value is sent only when chosen, so leaving it blank keeps the existing setting
and preserves the client_secret_post default.
This commit is contained in:
tin-berri 2026-07-03 10:25:58 -07:00 committed by GitHub
parent d0e785140c
commit b59ad212f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 253 additions and 39 deletions

View file

@ -91,6 +91,47 @@ describe("OAuthFormFields", () => {
});
});
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", () => {

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 TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
interface OAuthFlowStatus {
startOAuthFlow: () => void;
@ -101,6 +102,7 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
>
<TextInput placeholder="https://auth.example.com/oauth/token" className={fieldClassName} />
</Form.Item>
<TokenEndpointAuthMethodField isEditing={isEditing} />
<Form.Item
label={
<FieldLabel
@ -182,6 +184,7 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
>
<TextInput placeholder="https://example.com/oauth/token" className={fieldClassName} />
</Form.Item>
<TokenEndpointAuthMethodField isEditing={isEditing} />
<Form.Item
label={
<FieldLabel

View file

@ -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;

View file

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
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(),
@ -88,45 +89,6 @@ const defaultProps = {
/** 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();
@ -534,6 +496,84 @@ describe("CreateMCPServer", () => {
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 = {

View file

@ -4,6 +4,7 @@ import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"
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(),
@ -506,6 +507,68 @@ describe("MCPServerEdit (interactive OAuth)", () => {
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);

View file

@ -20,6 +20,7 @@ import MCPToolConfiguration from "./mcp_tool_configuration";
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";
@ -996,6 +997,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
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">

View file

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